D3 交互
参考
本文主要介绍 Brushes、Zooming 和 Dragging 模块
图表交互有两面性,它可以让阅读者探索数据,以更多的角度来理解数据;但是过于自由的探索,也可能让用户忽略数据的某些重要的信息。因此对于可交互的图表设计需要更用心,应该在图表的初始状态给予足够的引导和提示。而如果只是想提供图表说明传达已知的信息,则选择静态的图表会更高效。
对于交互图表的设计,Ben Shneiderman 给出了一个很好的指南:
Overview first,
zoom and filter,
then details on demand.
- 总览 overview 是图表的初始状态,应该展现数据的整体视图,以引导用户对数据的特定部分进行探索
- 缩放 zoom 和筛选 filter 是探索的手段,通过平移、缩放、聚焦筛选、刷选等方法选择所感兴趣的部分数据进行探索
- 细节按需显示 details on demand 是在读者希望知道特定的数据时,才通过 tooltips 等方式显示,这样可以保证图表的准确性
D3 提供了相应的模块 d3-brush、d3-zoom、d3-drag 为可视化图表实现常见的交互功能,其原理一般都是在元素/容器上设置事件监听器,它侦听的是 D3 自定义事件,如 brush
、zoom
、drag
等(实际上是先识别用户的原生的点击、拖拽、滚动、双指捏放等事件,具体「代理」了哪些原生事件,可以查看相应模块的 API,如 d3-zoom、d3-drag,经过 D3 处理这些原生事件会封装为特定的自定义交互事件抛出,在这些自定义事件对象中,会有交互相关的信息,如移动距离,缩放比例等),然后在回调函数中,基于这些事件信息,对页面的图形元素进行处理,如改变外观样式、移动位置、缩放视图等。
刷选
Brushing 刷选是在一维或二维空间对于区域进行选择的一种交互方式,通过鼠标的指针在点击后拖拽框选出一个特定的区域,常见的一个例子在散点图中刷选特定区域中的多个数据点,以进行局部研究。
d3-brush 模块通过监听在 SVG 上的鼠标(拖拽)或触摸事件来实现刷选操作。此外还可以提供更丰富的操作,如默认支持在选区内点击并拖拽以移动选区、通过点击移动选区的一边可以改变选区的大小、通过点击并拖拽(选区外)透明的覆盖层可以创建一个新的选区、按住 Alt
键可以从中间等距向四周扩张创建选区。而且还可以通过调用相应的方法,以编程的方式创建选区和移动选区。
创建刷选器
d3-brush 模块提供三种不同的方法创建不同功能的刷选器(以下称为 brush
):
d3.brush()
创建一个二维空间的刷选器d3.brushX()
创建一个 X 轴刷选器d3.brushY()
创建一个 Y 轴刷选器
刷选器既是一个方法,它接受选择集(一般是一个 <g>
元素构成的选择集)作为入参,然后刷选器可以针对选择集中的元素(即该元素/容器 <g>
)设置相应的事件监听器
svg.append("g")
.attr("class", "brush")
.call(d3.brush().on("brush", brushed));
// 一般通过 selection.call() 方法调用刷选器创建函数
// 这样 selection 选择集就会作为参数传递给刷选器创建函数
以上示例中,刷选器为选择集中的元素添加(D3 所定义的拖拽事件类型)brush
事件监听器,并设置了相应的处理函数 brushed
(一般在回调函数中通过数据和选区的坐标比对,间接知道哪些数据选中而哪些数据未选中,并对这些数据所对应的页面上的元素设置不同的样式,也可执行其他操作)
提示
如果希望移除刷选相关事件的所有监听器,可以将相应事件 .brush
的回调函数设置为 null
// group 是指包含 <g> 元素的选择集
group.on(".brush", null);
刷选器会创建一系列 SVG 元素以展示选区,并响应用户的刷选操作。这些元素都添加了相应的类名,可以通过特定的类名来为这些元素设置不一样的样式。
<!-- 一个二维空间刷选器所创建的元素 -->
<g class="brush" fill="none" pointer-events="all" style="-webkit-tap-highlight-color: rgba(0, 0, 0, 0);">
<!-- 具有类名 .overlay 元素覆盖在整个可刷选区域,假设刷选器应用在一个宽高尺寸为 960x500 的元素上-->
<rect class="overlay" pointer-events="all" cursor="crosshair" x="0" y="0" width="960" height="500"></rect>
<!-- 具有类名 .selection 元素覆盖在选区 -->
<rect class="selection" cursor="move" fill="#777" fill-opacity="0.3" stroke="#fff" shape-rendering="crispEdges" x="112" y="194" width="182" height="83"></rect>
<!-- 具有类名 .handle 放置在选区的不同位置,它们修改了该区域的鼠标样式,以便提示用户在选区的不同区域可以进行不同的交互 -->
<rect class="handle handle--n" cursor="ns-resize" x="107" y="189" width="192" height="10"></rect>
<rect class="handle handle--e" cursor="ew-resize" x="289" y="189" width="10" height="93"></rect>
<rect class="handle handle--s" cursor="ns-resize" x="107" y="272" width="192" height="10"></rect>
<rect class="handle handle--w" cursor="ew-resize" x="107" y="189" width="10" height="93"></rect>
<rect class="handle handle--nw" cursor="nwse-resize" x="107" y="189" width="10" height="10"></rect>
<rect class="handle handle--ne" cursor="nesw-resize" x="289" y="189" width="10" height="10"></rect>
<rect class="handle handle--se" cursor="nwse-resize" x="289" y="272" width="10" height="10"></rect>
<rect class="handle handle--sw" cursor="nesw-resize" x="107" y="272" width="10" height="10"></rect>
</g>
提示
实际上刷选操作与响应都是在这个元素 <g>
上进行的(并不是真的与页面的数据点元素进行交互)。它在数据点上所添加的覆盖层/容器(其覆盖范围大小通过方法 brush.extent()
设置),用以响应用户刷选操作,获取选区的坐标范围,然后再将获取得到的选区的坐标范围可以与数据点的坐标进行对比,再为数据点元素设置不同的样式,通过这种间接的方式,最后实现数据的选区效果。
选区
使用方法 d3.brushSelection(node)
可以获取在节点 node
中(绑定了刷选器)当前的刷选区域,因为在该节点内部(例如 <g>
容器),会有属性 element.__brush
记录着它的刷选状态)
所获得到的是一个表示选区坐标范围的数组(例如对于二维刷选的场景,其形式为 [[x0, y0], [x1, y1]]
;对于沿着 X 轴刷选场景,其形式为 [x0, x1]
);如果在该节点里没有创建选区,则返回 null
操作刷选区
刷选器也是一个对象,具有多种方法来增删、修改选区:
brush.move(group, selection[, event])
设置选区- 第一个参数
group
是需要刷选的选择集(一般其中含有一个<g>
元素/容器) - 第二个参数
selection
表示选区,它可以是数组或null
(无选中的元素)
如果是二维空间的刷选器,则数组格式是[[x0, y0], [x1, y1]]
以表示选区的横纵坐标范围;如果是 X 轴刷选器,则数组格式是[x0, x1]
以表示选中的横坐标范围;如果是 Y 轴刷选器,则数组的格式是[y0, y1]
以表示选中的纵坐标轴范围提示
它也可以是一个返回数组或
null
的函数,以便根据不同的情况动态生成选区。该函数会被选择集中的每一个元素调用,并依次传入两个参数:- 当前所遍历的元素绑定的数据 datum
d
- 当前所遍历的元素在选择集合中的索引 index
i
而函数内的
this
指向当前所遍历的元素节点 - 当前所遍历的元素绑定的数据 datum
- 第一个参数
brush.clear(group[, event])
用于清除选区,和brush.move(group, null)
作用一样brush.extent([extent])
用于设置可刷选区域。刷选器会在该区域创建一个<rect class="overlay" ...>
元素作为覆盖层,响应用户的刷选操作。
其(可选)参数extent
是一个数组,格式为[[x0, y0], [x1, y1]]
提示
入参也可以是一个返回数组的函数,以便根据不同的情况动态生成可刷选区域,它会被(刷选器所绑定的)选择集中的每一个元素调用,并依次传入两个参数:
- 参数
d
当前所遍历的元素绑定的数据 - 参数
i
当前所遍历的元素在选择集中的索引值
提示
如果没有设置可刷选区域,采用 SVG 的大小范围作为默认的区域
jsfunction defaultExtent() { var svg = this.ownerSVGElement || this; if (svg.hasAttribute("viewBox")) { svg = svg.viewBox.baseVal; return [[svg.x, svg.y], [svg.x + svg.width, svg.y + svg.height]]; } return [[0, 0], [svg.width.baseVal.value, svg.height.baseVal.value]]; }
- 参数
约束刷选
brush.filter([filter])
用于判断是否执行刷选操作。参数filter
是一个返回布尔值的函数,它接收当前的刷选事件event
作为参数,当返回的是 falsy 时忽略刷选操作。它用以限制特定条件下不响应刷选操作。
参数filter
的默认值如下jsfunction filter(event) { // 对于设置为右手操作的鼠标 // 当使用左键时,event.button 为 0 // 当使用右键时,event.button 为 2 return !event.ctrlKey && !event.button; }
因此按下Ctrl
或使用鼠标的次级按键(对于右手用户,次级按键一般是指鼠标的右键)时默认在可刷选区域内是无法进行刷选操作,因为这些操作一般有其他用途brush.touchable([touchable])
判断浏览器是否支持触控操作,参数touchable
是一个返回布尔值的函数,只有返回值为 truthy 时,才会在选择集的元素中注册(以触控方式)刷选事件的监听器
参数touchable
的默认值如下jsfunction touchable() { return navigator.maxTouchPoints || ("ontouchstart" in this); }
brush.keyModifiers([modifiers])
以设置刷选时是否同时监听键盘按键。参数modifiers
是一个布尔值,默认为true
,这时刷选器除了监听鼠标操作,还会监听键盘按键操作,例如在刷选时同时按下Alt
键,这会构建一个从中间向四周同时扩展的选区(这和很多图形编辑软件的选择工具的操作逻辑类似)。brush.handleSize([size])
设置选区中各个操作柄 handle 大小,默认大小为6
注意
该方法需要在刷选器与选择集进行绑定之前调用
处理刷选事件
brush.on(typenames[, listener])
为选择集中的元素(一般只含有一个 <g>
元素作为容器)设置刷选相关事件的监听器。
第一个参数 typenames
是需要监听的刷选相关事件,D3 提供了 3 种刷选相关事件类型:
start
刷选开始时(如鼠标按下操作)所触发的事件brush
刷选过程中(如鼠标移动操作)所触发的事件end
刷选结束时(如松开按键操作)所触发的事件
提示
可以在事件后添加名称 name
并用 .
分隔,如 brush.on('brush.foo', listener)
,这样就可以为一个刷选事件类型添加多个不同的处理函数
提示
如果希望移除刷选相关事件的所有监听器,可以将相应事件 .brush
回调函数设置为 null
brush.on(".brush", null);
第二个参数 listener
是事件处理函数,它会在相应的刷选类型事件触发时被调用,并依次传递 2 个参数:
event
刷选事件对象,该对象会暴露一些关于当前刷选信息的属性:event.target
当前触发刷选事件的刷选器event.type
当前的刷选类型,可以是start
、brush
或end
event.selection
当前的选区,一个表示刷选区域的坐标范围的数组event.mode
当前刷选的模式,可以是drag
(移动选区)、space
(按住空格键移动选区)、handle
(通过框选创建选区,也可能时通过拖动选区的四个角或四条边来调整选区) 或center
(按住Alt
键创建选区)event.sourceEvent
实际触发的基础事件,如mousemove
或touchmove
事件
d
当前调用刷选器的元素所绑定的数据 datum
平移缩放
Panning 平移和 Zooming 缩放是一种常见的交互,通过点击和拖拽可以对图像进行平移(相应地修改图形的 CSS translate
样式属性),通过滚动鼠标的滚轮/(在触屏设备上)双指捏放可以对图像进行缩放(相应地修改图形的 CSS scale
样式属性),让使用者在有限的视图中聚焦探索感兴趣的部分。
d3-zoom 模块提供简单且灵活的方式实现对 SVG、HTML 或 Canvas 的平移和缩放,除了可以操作选择集,还支持对比例尺和坐标轴的缩放平移。该模块也提供相应的方法,可以通过编程的方式实现缩放平移,还可以为此设置顺滑的过渡动画。
创建缩放器
使用方法 d3.zoom()
创建一个缩放器(以下称为 zoom
)
它既是一个方法,可以接收选择集作为参数 zoom(selection)
,为选择集中的元素(一般是包含数据的容器 <g>
元素)添加相应的缩放事件监听器,并为它们设置变换 transform 的初始值
// 一般通过 selection.call() 方法调用缩放器创建函数
// 这样 selection 选择集就会作为参数传递给缩放器创建函数
selection.call(d3.zoom().on("zoom", zoomed));
以上示例中,缩放器为选择集中的元素(容器 <g>
)添加(D3 所定义的缩放事件类型)zoom
事件监听器,并设置了相应的处理函数 zoomed
(一般在回调函数中进行 transform 相关样式属性的修改,实现元素在特定视图内的缩放平移,一般应用于容器 <g>
元素,这样容器的内的数据整体都会跟随着缩放平移了)
提示
如果希望移除缩放相关事件的监听器,可以为将相应事件回调函数设置为 null
selection.on(".zoom", null);
// 也可以只移除特定的缩放事件的监听器,如滚轮缩放
selection.on("wheel.zoom", null);
它也是一个对象,提供多种与缩放变换相关的方法,如通过设置监听器用户触发的原生点击拖拽等事件,以触发缩放变换;也可以调用相应的方法以编程的方式来执行缩放;还可以通过相应的方法来约束缩放变换行为。
缩放变换对象
当缩放器绑定到选择集中的元素(一般是包含数据的容器 <g>
元素),除了添加相应的缩放事件监听器,还会在元素上存储它的缩放状态信息 element.__zoom
提示
由于缩放器可以操作不同的元素,所以缩放状态并不是存储在缩放器上,而是在选择集的元素中,这样可以方便地对不同的元素分别进行缩放操作,且它们的缩放状态都是独立的。
缩放变换对象/缩放变换值(以下称为 transform
)具有以下属性:
transform.x
沿水平轴的平移量transform.y
沿垂直轴的平移量transform.k
缩放比例
用数学方式,一个变换矩阵表示
通过该矩阵可以计算出容器内任意点 经过变换后的位置
提示
目前(本文中所指的 D3 版本是 v7.3.0)缩放变换对象仅包含缩放 scale 和平移 translation 信息,未来的 D3 版本可能会提供更丰富的缩放信息,例如在变换矩阵中添加旋转变换信息,尽管这会造成向后兼容的问题
注意
缩放变换对象里的属性应该是只读的,不能通过赋值的方式直接对其进行修改,以求触发新的缩放变换。而应该采用其他方法,如 zoom.scaleBy()
、zoom.scaleTo()
、zoom.translateBy()
(而方法 zoom.transform()
是用于为元素/容器设置一个全新的缩放变换值)
D3 提供了一个特殊的缩放变换对象:d3.zoomIdentity
标准缩放变换对象,它的缩放比例与平移值分别为:、
获取缩放变换对象
可以在 zoom.on(typenames[, listener])
缩放事件监听器的回调函数中,通过 event.transform
属性获取该元素当前的缩放变换值。
提示
也可以通过方法 d3.zoomTransform(node)
获取指定容器/元素的缩放状态,该方法一般用于以编程的方式触发的缩放中。传入的参数 node
需要是是一个 DOM 节点,而不是选择集(如果是选择集,假设选择集中只有一个元素,则可以通过方法 selection.node()
来获取选择集中的元素)
如果方法 d3.zoomTransform(node)
传入的节点没有存储缩放信息,该方法会返回的是距离它最近的含有缩放信息的祖先节点的缩放变换值;如果祖先节点也没有存储缩放信息,则返回一个标准缩放变换值 identity transformation(其水平和垂直的平移量均为 0
,缩放比例为 1
)
缩放变换对象的方法
缩放变换对象还有一些实用的方法:
transform.translate(x, y)
基于传入的水平和垂直方向上的平移增量x
和y
,返回一个新的缩放变换对象,其平移量是 、提示
其中 、 分别是原来的缩放变换对象
transform
的平移量, 是原来的缩放变换对象transform
的缩放比例,transform.scale(k)
基于传入的缩放比例增量,返回一个新的缩放变换对象,其缩放比例是提示
其中 是原来的缩放变换对象
transform
的缩放比例, 是缩放比例的增量
提示
可以利用一个特殊的缩放变换对象 d3.zoomIdentity
(标准缩放变换对象)通过调用以上方法,基于特定的 k
、x
、y
值,构建出一个相应的缩放变换对象
// 其中 x, y, k 为缩放变换增量
const t = d3.zoomIdentity.translate(x, y).scale(k);
也可以基于特定的 k
、x
、y
值,使用方法 new d3.ZoomTransform(k, x, y)
构建一个缩放变换对象
transform.apply(point)
传入一个点的原始坐标,通过缩放变换对象处理,返回变换后的坐标
入参point
是一个数组,表示点的原始坐标 ,假设缩放变换对象的平移和缩放比例值依次为 、、,则变换后的坐标是提示
如果只想获取变换后的横坐标或纵坐标,可以使用缩放变换对象的相应方法
transform.applyX(x)
或transform.applyY(y)
transform.invert(point)
传入一个点的变换后的坐标,返回在缩放变换对象处理 (变换)前的坐标
入参point
是一个数组,表示点变换后的坐标 ,假设缩放变换对象的平移和缩放比例值依次为x
、y
、k
,则变换前的坐标是提示
如果只想获取变换前的横坐标或纵坐标,可以使用缩放变换对象的相应方法
transform.invertX(x)
或transform.invertY(y)
如果希望对比例尺也进行缩放(常用于在缩放过程中同步修正坐标轴的显示),可以实用以下方法
transform.rescaleX(x)
传递(横轴的)原始的连续型的比例尺x
,返回一个定义域经过缩放变换的比例尺(这样映射关系就会相应的改变,会考虑上缩放变换对象transform
的缩放比例)提示
返回的是一个新的比例尺,它源于原始比例尺
x
的拷贝。因此传入的比例尺x
并未改变。
该方法的源码如下,具体的逻辑讲解可以看这篇文章:js// 缩放后,对比例尺进行重新计算 // 由于缩放后,SVG 宽度不变(值域不变),但是可显示的数据(定义域)范围变了 function rescaleX(x) { // 由于值域不变,所以从值域入手 // 先假设值域变换了,所以对原来的值域 x.range()(一个数组,里面的各个元素)进行反向变换 // 求出变化前的值域 range 应该是什么 // arr.map(transform.invertX, transform) 数组方法 map 的第二个参数是用于设定函数内的 this 指向 var range = x.range().map(transform.invertX, transform), domain = range.map(x.invert, x); // 然后基于(假设的)变换前的值域,求出相应的定义域 // 将求出的数组 domain 作为新的定义域(而最后保留值域不变) // 这样得到的新的比例尺就可以**将缩放比例考虑进映射关系中** return x.copy().domain(domain); }
注意
比例尺
x
值域的插值方法必须使用d3.interpolateNumber
插值器,而不能使用continuous.rangeRound
,因为会降低continuous.invert
的准确性并且可能导致求出的(缩放之后)domain
的不一致。transform.rescaleY(y)
传递(纵轴的)原始的连续型的比例尺y
,返回一个定义域经过缩放变换的比例尺。该方法内部代码和实现原理与横轴的比例尺缩放变换一样。
提示
缩放改变了定义域与值域的缩放关系(页面上原来的某个位置对应于某个数据量的关系不成立了),需要更新比例尺
可以考虑改变值域,也可以考虑改变定义域
使用以上 D3 提供的方法 transform.rescaleX(x)
或 transform.rescaleY(y)
来更新比例尺,是通过改变定义域(保持值域不变)
另一种方式则是手动改变值域(而保持定义域不变)来更新比例尺,相关示例可以参考 Zoomable Bar Chart 里的具体代码
提示
一般将缩放变换对象的 x
、y
、k
直接用于元素/容器的 CSS transform
样式属性上,这种方式称为图像化缩放 Geometric Zoom,这种方法会将元素/容器的所有东西(属性)都同时无差别地缩放平移。
相应地,有另一种缩放变换实现方案,称为语义化缩放 Semantic Zoom,之所以称为「语义化」是因为它不依赖于改变 CSS 的transform
样式属性,即而是先使用以上的方法 transform.rescaleX(x)
或 transform.rescaleY(y)
获得缩放后的新比例尺,然后用新比例重新计算出元素的定位和大小(重新进行数据到视觉元素的映射),该方式需要重绘整个画面
图像化缩放是通过 CSS 将元素/容器整体进行缩放;而语义化缩放是重新计算,尽管如此,但语义化缩放有一个优势,由于通过变换比例尺(坐标轴)以实现缩放,这样可以控制只有特定的图形元素的特定属性进行变换,而让标注文本、刻度线等辅助展示数据的元素可以维持大小不变。
关于两种缩放策略的解释和对比,可以看《D3 Zoom - The Missing Manual》这一篇文章。
应用缩放变换
通过设置元素/容器 CSS transform
样式属性,将缩放变换 transform
应用于元素/容器上,这样里面的数据点就会有(整体)移动或缩放的效果
// 对于普通的 HTML 元素或 SVG 元素方法一样
// 可以设置 style
div.style("transform", "translate(" + transform.x + "px," + transform.y + "px) scale(" + transform.k + ")");
div.style("transform-origin", "0 0");
// 或设置 attr
g.attr("transform", "translate(" + transform.x + "," + transform.y + ") scale(" + transform.k + ")");
以上方式需要将缩放变换值的各个属性拼接为字符串(而且平移操作需要在缩放操作前),才可以作为元素的 CSS transform
样式属性的值,其实可以直接使用缩放变换对象
g.attr("transform", transform);
因为 D3 会隐式调用缩放变换对象的方法 transform.toString()
自动将其转换为适用于 CSS transform
样式属性值的字符串,该方法的源码如下
function toString() {
return "translate(" + this.x + "," + this.y + ") scale(" + this.k + ")";
}
提示
Canvas 与 HTML/SVG 元素所采用的方式不一样,对于 Canvas 可以通过调用它的上下文 context
的相应方法来应用变换
context.translate(transform.x, transform.y);
context.scale(transform.k, transform.k);
执行缩放
缩放器也是一个对象,可以调用相应的方法来执行缩放操作,以实现编程式缩放:
zoom.transform(selection, transform[, point])
对选择集的元素进行变换(缩放平移) 第一个参数selection
可以是一个选择集或一个过渡对象。- 如果是一个选择集,则选择集中的元素的当前的变换属性样式会设置为第二个参数
transform
的值,并同时依次抛出start
、zoom
、end
缩放相关事件 - 如果是一个过渡对象,则调用
d3.interpolateZoom
方法,使用插值器计算出过渡过程中变换属性样式的一系列值。并在过渡开始时分发start
事件,在过渡的每一帧都会分发zoom
事件,在过渡结束(或中断)时分发end
事件。过渡效果会尝试让缩放平移围绕指定的点(通过第三个(可选)参数指定,默认为视图的中点)在视觉感受上移动是最小化的
第二个参数transform
是一个表示(D3 格式的)缩放变换 zoom transform 的值。提示
也可以是返回缩放变换值的函数,则选择集的元素会依次调用该函数,并依次传递两个参数:
event
当前的缩放事件d
当前所遍历的元素所绑定的数据 datum
而函数内的
this
指向当前所遍历的元素节点
第三个(可选)参数point
是缩放平移过渡的参考点,可以是一个表示坐标点的数组[x, y]
。也可以是一个返回数组的函数,则选择集的元素会依次调用该函数,也会依次传递两个参数。js// 一般通过 selection.call() 方式调用该方法以设置缩放变换 // 这样 selection 选择集就会作为第一个参数传递给该方法 // d3.zoomIdentity 是一个表示初始状态的缩放变换值 selection.call(zoom.transform, d3.zoomIdentity); // 以上示例的作用是将视图中的元素缩放平移变换「复位」 reset // 也可以通过 transition.call() 方式调用该方法以设置缩放变换,该过程使用过渡动画 selection.transition().duration(750).call(zoom.transform, d3.zoomIdentity);
- 如果是一个选择集,则选择集中的元素的当前的变换属性样式会设置为第二个参数
以上方法是基于一个新的缩放变换值 zoom transform 进行变换的,且不会受缩放限制 zoom.scaleExtent([extent])
和 zoom.translateExtent([extent])
的影响。如果希望基于已有的变换而进一步进行变换,并考虑缩放限制则可以使用 zoom.translateBy()
、zoom.translateTo()
、zoom.scaleBy()
或 zoom.scaleTo()
方法,可以将它们看作是 zoom.transform()
的简便方法。
zoom.translateBy(selection, x, y)
基于当前变换,对选择集的元素进一步执行平移操作,即进行的是相对变换。
💡 新的变换值通过原变化值求出:,(其中 和 是当前/原变换值,而 和 是平移变换相对于原值的增量, 是缩放变换比例)
第一个参数selection
可以是一个选择集,也可以是一个过渡对象。会对其中的元素执行变换操作。
第二、第三个参数x
和y
是相对于当前坐标(原变换)的平移变换增量值提示
第二、第三个参数
x
和y
也可以是一个返回平移增量值的函数,它会被选择集中的元素依次调用,并传递两个参数:d
当前所遍历的元素所绑定的数据 datumi
当前所遍历的元素在分组中的索引 index
而函数内的
this
指向当前所遍历的元素节点zoom.translateTo(selection, x, y[, p])
对选择集的元素执行平移操作,其变换的方向和距离是基于将点[x, y]
平移到给定的点p
处提示
变换是基于两个参照点:,(其中 和 分别是参照点
p
的横纵坐标)
第一个参数selection
可以是一个选择集,也可以是一个过渡对象。会对其中的元素执行变换操作。
第二、第三个参数x
和y
是需要一个点的坐标位置
第四个(可选)参数p
是参照点,需要通过变换将点[x, y]
移动到此处,点p
默认是视图的中点提示
第二、第三、四个参数
x
、y
和p
也可以是一个返回点的坐标位置的函数,它会被选择集中的元素依次调用,也是传递递两个参数。zoom.scaleBy(selection, k[, p])
对选择集的元素进行缩放操作,是基于当前的变换再进一步进行缩放的提示
视图新的缩放比例是基于原有的缩放比例计算得出的:(其中 是原缩放比例)
第一个参数selection
可以是一个选择集,也可以是一个过渡对象。会对其中的元素执行变换操作。
第二个参数k
是缩放比例的倍增量
第三个(可选)参数p
是构建平滑的缩放过渡的参照点,默认为视图的中点,该参考点在缩放过程中会发生移动提示
第二、第三个参数
k
和p
也可以是一个返回相应值的函数,它会被选择集中的元素依次调用,也是传递递两个参数。zoom.scaleTo(selection, k[, p])
对选择集的元素进行缩放操作,并将缩放比例设置为k
提示
视图新的缩放比例是传入的参数:
第一个参数selection
可以是一个选择集,也可以是一个过渡对象。会对其中的元素执行变换操作。
第三个(可选)参数p
是构建平滑的缩放过渡的参照点,默认为视图的中点,该参考点在缩放过程中不会发生移动提示
第二、第三个参数
k
和p
也可以是一个返回相应值的函数,它会被选择集中的元素依次调用,也是传递递两个参数。
在缩放时如果使用过渡动画,缩放器提供了相应的方法设置过渡动画的参数:
zoom.duration([duration])
设置通过双击(鼠标双击或触屏设备的 double-tap)所触发的缩放,其过渡动画的持续时间。参数duration
是数值,表示过渡动画的持续时间(单位为毫秒),如果传递一个非正数,则缩放立即生效而没有平滑的动效,默认值为250
提示
如果希望禁用双击放大功能,可以将
dbclick
双击事件(事件具有名称zoom
)的回调函数设置为null
jsselection .call(zoom) // 为选择集的元素设置了缩放监听器后,取消监听双击缩放事件 .on("dblclick.zoom", null);
zoom.interpolate([interpolate])
设置缩放平移过渡动画的插值器。参数interpolate
是一个返回插值器的函数(也成为插值器工厂函数 interpolation factory),默认值是 D3 内置的插值器d3.interpolateZoom
,实现平滑的过渡效果提示
如果希望在两个视图「线性」过渡,则可以尝试使用 D3 的另一个内置的插值器
d3.interpolate
约束缩放
zoom.constrain([constrain])
对缩放平移操作进行约束。提示
可以将它视为通用的缩放约束方法,可以对变换操作进行复杂的限制。
其入参是一个函数,该函数依次接收三个参数,最后返回一个经过约束的变换值:transform
原本需要执行的缩放变换对象extent
视图范围translateExtent
平移的范围
默认的约束函数如下,其作用是确保视图范围不大于可平移的范围,这样用户就可以通过移动,将图像的各个部分移到视图的任何地方jsfunction constrain(transform, extent, translateExtent) { var dx0 = transform.invertX(extent[0][0]) - translateExtent[0][0], dx1 = transform.invertX(extent[1][0]) - translateExtent[1][0], dy0 = transform.invertY(extent[0][1]) - translateExtent[0][1], dy1 = transform.invertY(extent[1][1]) - translateExtent[1][1]; return transform.translate( dx1 > dx0 ? (dx0 + dx1) / 2 : Math.min(0, dx0) || Math.max(0, dx1), dy1 > dy0 ? (dy0 + dy1) / 2 : Math.min(0, dy0) || Math.max(0, dy1) ); }
zoom.extent([extent])
设置视图范围 viewport extent。
参数extent
是一个数组[[x0, y0], [x1, y1]]
,其中[x0, y0]
表示视图范围的左上角,而[x1, y1]
表示视图范围的右下角。
参数extent
也可以是一个返回数组的函数,它会被选择集中的元素依次调用,并传递当前所遍历的元素所绑定的数据 datumd
作为参数。而函数内的this
指向当前所遍历的元素节点。提示
如果缩放器绑定的是普通的 HTML 元素,则视图范围的默认值
[[0, 0], [width, height]]
即与元素的宽高大小相同;如果缩放器绑定的是 SVG 类型的元素,则视图范围的默认值是 SVG 的viewBox
(如果没有设置viewBox
属性,就使用 SVG 的宽高,即 viewport)。
视图的范围 viewport extent 对于一些函数有影响,如通过zoom.scaleBy()
和zoom.scaleTo()
方法触发的变换,其视图的中心会保持不变;视图中心和大小会影响使用插值器d3.interpolateZoom
创建的过渡动画的路径;而平移范围 translate extent 的约束需要依赖视图范围(平移范围应该大于视图范围)。zoom.translateExtent([extent])
设置平移范围 translate extent。
参数extent
是一个数组[[x0, y0], [x1, y1]]
,其中[x0, y0]
表示平移范围的左上角,而[x1, y1]
表示平移范围的右下角。默认值是[[-∞, -∞], [+∞, +∞]]
提示
该方法虽然约束的是平移操作,但可能造成缩小时平移的发生。
注意
该约束在通过
zoom.scaleBy()
、zoom.scaleTo()
、zoom.translateBy()
方法执行变换时生效;但是通过zoom.transform()
方法执行变换时不生效
说明
以上三个方法提及的两个特殊的范围:视图范围 viewport extent 和平移范围 translate extent。
我们可以将 SVG 想象为一块无限大的画布(其中心点假设存在,且为 (0, 0)
,横轴正方向是向右,纵轴的正方向是向下)。
而我们是通过望远镜观测画布的,其中视图范围 viewport extent(默认是 SVG 的 viewBox)就是望远镜的取景框,它约束着我们可以看到 SVG 画布的哪一个部分。
当我们执行平移操作时实际是移动了望远镜而非画布,即更改了视图范围 viewport extent,而其中平移范围 translate extent 限制我们望远镜的移动「幅度」。
还有一点值得注意,视图的移动和元素的移动是反向的,即当我们想将元素往右下角移动时,其实视图(范围)是需要往左上角改变。
通过设置视图范围 viewport extent 的大小,以及通过平移范围 translate extent 来约束视图(范围,一个数组)可以修改的位置,可以间接来限制元素可以平移的位置,通过这两个特殊范围的配合可以实现特定的元素不被移出画面外这一需求。
zoom.scaleExtent([extent])
设置缩放比例的范围。参数extent
是一个的数组[k0, k1]
,表示缩放比例的范围,其中k0
是可设置的最小缩放比例,k1
是可设置的最大缩放比例。默认范围是
如果达到了约束的缩放比例极限时,即使用户继续滑动鼠标滚轮,缩放变换也会被忽略。提示
以上方法限制视图的缩放,但可能会造成一个「副作用」,即当视图缩放达到了约束的缩放比例极限时,用户还继续滚动就会造成页面的滚动(如果当时页面是可滚动的),如果希望修正这个「副作用」,可以在相应的选择集上监听
wheel
事件并取消它的默认行为jsselection .on(zoom) // 为选择集的元素设置了缩放监听器后,取消 wheel 事件的默认行为 .on("wheel", event => event.preventDefault());
注意
该约束在通过
zoom.scaleBy()
、zoom.scaleTo()
、zoom.translateBy()
方法执行变换时生效;但是通过zoom.transform()
方法执行变换时不生效zoom.filter[(filter)]
用于判断是否执行缩放变换操作。参数filter
是一个返回布尔值的函数,当返回的是 falsy 时忽略变换操作。它用以限制特定条件下不响应缩放变换操作。
函数filter
接收当前的缩放事件event
和当前调用缩放器的选择集的元素所绑定的数据 datumd
作为参数,而函数内的this
指向当前元素节点。
其默认值如下,因此按下Ctrl
(但是可以在滚动鼠标滑轮时按下Ctrl
)或使用鼠标的次级按键(对于右手用户,次级按键一般是指鼠标的右键)时,默认是无法进行缩放平移操作,因为这些操作一般有其他用途jsfunction filter(event) { // 对于设置为右手操作的鼠标 // 当使用左键时,event.button 为 0 // 当使用右键时,event.button 为 2 return (!event.ctrlKey || event.type === 'wheel') && !event.button; }
zoom.wheelDelta([delta])
设置鼠标滚轮的每次滚动的 delta 值,参数delta
是一个函数,最后返回修改后的 delta 值 。
参数delta
默认值如下jsfunction wheelDelta(event) { return -event.deltaY * (event.deltaMode === 1 ? 0.05 : event.deltaMode ? 1 : 0.002); }
该值用于在鼠标滚轮时计算缩放比例 (其中 是原始的缩放比例),例如当 时,视图的元素会缩小一半;当 时,视图的元素会放大一倍zoom.clickDistance([distance])
设置点击事件中,鼠标按下与放开鼠标之间允许的最大距离(该距离通过点击事件的event.clientX
、event.clientY
计算得到),如果大于等于该距离,就不会抛出点击事件。提示
可以想象以鼠标按下点为圆心,以参数
distance
为半径,在该圆内释放鼠标,都会抛出点击事件,在圆外(或圆周上)放开鼠标,点击事件都会被忽略(因为此时更应该触发拖拽事件)。参数默认值distance
是0
该方法可用于优化点击放大的场景,而原始画面中有大量较小的元素,从鼠标的按下到释放可能会发生微小的移动,避免识别为对该元素的拖拽操作,可以通过设置「可移动式点击的最大距离」,来区分点击事件和拖拽事件。zoom.tapDistance([distance])
对于触屏设备在双击时,两次点击允许的最大距离(该距离通过首次点击的touchstart
和第二次点击的touchend
事件的event.clientX
、event.clientY
计算得到),如果大于等于该距离,就不会抛出dblclick
双击事件。提示
其应用场景和前一个方法类似,一般是为了区分双击事件和拖拽事件,参数默认值
distance
是10
处理缩放事件
使用方法 zoom.on(typenames[, listener])
监听缩放事件,并执行回调函数(一般是操作容器在视图中执行缩放平移 transform 变换)
第一个参数 typenames
是需要监听的缩放平移相关事件,D3 提供了 3 种刷选相关事件类型:
start
缩放开始时(如鼠标按下操作)所触发的事件zoom
缩放过程中(如鼠标移动操作)所触发的事件end
缩放结束时(如松开按键操作)所触发的事件
提示
可以在事件后添加名称 name
并用 .
分隔,如 zoom.on('zoom.foo', listener)
,这样就可以为通过一个刷选事件类型添加多个不同的处理函数
提示
如果希望移除缩放相关事件的监听器,可以为将相应事件回调函数设置为 null
zoom.on(".", null);
第二个参数 listener
是事件处理函数,它会在相应的缩放类型事件触发时被调用,而且依次接收 2 个参数:
event
缩放事件对象,该对象会暴露一些关于当前缩放信息的属性:event.target
当前触发缩放事件的缩放器event.type
缩放事件的类型,可以是start
、zoom
、end
event.transform
当前的缩放变换值,一般用于设置容器/元素的 CSStransform
样式属性,实现画面元素的移动缩放event.sourceEvent
触发缩放的原始事件,如mousemove
或touchmove
d
当前调用缩放器的元素所绑定的数据 datum
而回调函数内的 this
指向当前的元素
拖拽
Drag-and-drop 拖拽后释放是常见的一种交互方式,drag 是在物体上按下鼠标(或触屏设备上点按),在按住鼠标(或触屏设备上按住)的同时移动,最后释放鼠标按键的操作就是 drop 可以将物体拖拽到新的位置。
d3-drag 模块提供简单且灵活的方式实现对 SVG 或 HTML 的元素进行拖拽移动操作(甚至支持 Canvas),常用于 force-directed graph 力导向图的交互中。而且这个交互还可以和其他交互结合使用,如 d3-zoom 实现对画面整体的缩放平移和对特定元素的移动。
创建拖拽器
使用方法 d3.drag()
创建一个拖拽器(以下称为 drag
)
它既是一个方法,可以接收选择集作为参数 drag(selection)
,为选择集中的元素添加相应的拖拽事件监听器
// 一般通过 selection.call() 方法调用拖拽器创建函数
// 这样 selection 选择集就会作为参数传递给拖拽器创建函数
d3.selectAll(".node").call(d3.drag().on("start", started));
以上示例中,拖拽器为选择集中的元素添加(D3 所定义的拖拽事件类型)start
事件监听器,并设置了相应的处理函数 started
(一般在回调函数中对被拖拽的元素进行相关样式属性的修改,如 <circle>
元素的 cx
、cy
属性,以实现元素的移动)
提示
如果希望移除拖拽相关事件的监听器,可以为将相应事件的回调函数设置为 null
selection.on(".drag", null);
拖拽器也是一个对象,有多种方法用于配置拖拽相关信息:
drag.container([container])
该方法用于设置当前拖拽行为的容器。
入参container
是一个元素,或一个返回元素函数,以这个元素作为容器。提示
容器作为一个参考物,会传递给
d3.pointer
指针,影响着当前拖拽事件的event.x
和event.y
属性,以此来决定被拖拽对象的(相对)坐标。
其默认值是被拖拽对象的父元素节点(即拖拽器所绑定的选择集的父节点 parent node)jsfunction container() { return this.parentNode; }
提示
以上将父节点作为默认值适用于 HTML 或 SVG 元素。但是对于 Canvas 而言则并不恰当,由于 Canvas 里只有一个画布,画布里元素没有构成树形结构,可以返回
this
以当前触发拖拽事件的元素作为容器jsfunction container() { // 可以返回事件触发的对象,也就是 Canvas 自身 return this; }
也可以显式地指定 Canvas 作为容器
jsdrag.container(canvas)
drag.subject([subject])
该方法用于设置当前拖拽目标 subject 的访问器提示
拖拽目标是在原生事件(如
mousedown
或touchstart
)触发时指定的,代表了指被鼠标点击(或触屏设备中被按中)并拖拽的元素。然后可以在随后分发的(D3 自定义的)拖拽事件start
的事件对象相应的属性event.subject
获取到它。
参数subject
是拖拽目标的访问器,可以是一个对象,也可以是一个返回对象的函数。如果是一个函数,它会依次接收 2 个参数:event
拖拽发生前事件,即 D3 自定义的beforestart
事件类型。使用event.sourceEvent
可以获取元素实际所接收到的原生事件。使用event.identifier
可以获取触摸事件中表示触摸点的标识符。通过event.x
和event.y
可以获取指针相对于拖拽容器的坐标d
当前元素所绑定的数据,应该包含表示元素位置的x
、y
属性
则函数内部的this
指向元素节点jsfunction subject(event, d) { // 默认返回选择集中被拖拽元素所绑定的数据 datum // 如果被拽元素没有绑定数据,则返回一个包含当前指针坐标信息的对象 return d == null ? {x: event.x, y: event.y} : d; }
访问器返回的对象应该具有x
、y
属性,以表示拖拽目标被移动前的坐标信息,这样准确地知道拖拽目标的原始位置具体与指针的详细距离(在拖拽过程中可以保持它们之间的距离不变),这可以避免拖拽开始时出现的「抖动」现象。提示
假设拖动的是一个圆,而由于在 SVG 中绘制圆时,一般通过圆心的坐标对其,但是使用鼠标拖拽圆时,指针可能不是正好点击到圆心再进行拖拽,如果访问器返回对象中没有提供
x
、y
属性,则无法维持指针与圆(圆心)的相对位置,那么移动开始时会先将圆心的坐标进行「矫正」,将圆心矫正到与指针的坐标一致的位置上,再让圆跟随指针移动,看起来就像在拖拽开始时发生了「抖动」。注意
如果访问器最后返回的是
null
或undefined
则不会触发拖拽事件。提示
对于 Canvas 而言,画布将所有的元素绘制为一个「扁平化」的像素图(里面没有各自「独立」 DOM 元素),所以以上方法无论用户点击拖拽画布何处,默认返回的是
<canvas>
元素所绑定的数据。因此 Canvas 一个需要自定义subject
参数,以获得更精准的拖拽信息js// 自定义 subject 参数,它是一个函数 // 入参是当前拖拽事件 event,主要包括指针的位置信息 function subject(event) { let n = circles.length, // 假设画布中绘制了多个圆 i, dx, dy, d2, s2 = radius * radius, circle, subject; // 通过分别手动比对 Canvas 画布中所有的圆的坐标与当前指针的位置 for (i = 0; i < n; ++i) { circle = circles[i]; dx = event.x - circle.x; dy = event.y - circle.y; // 获取当前指针「悬浮」在哪个圆上 d2 = dx * dx + dy * dy; if (d2 < s2) subject = circle, s2 = d2; } // 返回当前指针最靠近的圆的相关信息 return subject; }
提示
以上示例可以借助 D3 一些模块的已封装的方法来简化代码,如
quadtree.find
方法、simulation.find
方法、delaunay.find
方法
约束拖拽
drag.filter([filter])
用于判断是否执行拖拽操作。参数filter
是一个返回布尔值的函数,它接收当前的原生事件event
作为参数,当返回的是 falsy 时忽略刷选操作。它用以限制特定条件下不响应刷选操作。
参数filter
的默认值如下,因此按下Ctrl
或使用鼠标的次级按键(对于右手用户,次级按键一般是指鼠标的右键)时默认无法进行拖拽操作,因为这些操作一般有其他用途jsfunction filter(event) { // 对于设置为右手操作的鼠标 // 当使用左键时,event.button 为 0 // 当使用右键时,event.button 为 2 return !event.ctrlKey && !event.button; }
drag.touchable([touchable])
判断浏览器是否支持触控操作,参数touchable
是一个返回布尔值的函数,只有返回值为 truthy 时,才会在选择集的元素中注册(以触控方式)拖拽事件监听器
参数touchable
的默认值如下jsfunction touchable() { return navigator.maxTouchPoints || ("ontouchstart" in this); }
zoom.clickDistance([distance])
设置点击事件中,鼠标按下与放开鼠标之间允许的最大距离,如果大于等于该距离(就不会抛出点击事件)触发拖拽事件。参数distance
的默认值是0
d3.dragDisable(window)
用于阻止给定的window
下原生的 drag-and-drop 拖拽行为以及文本选中行为。
该方法会在鼠标按下时,即mousedown
事件被触发时调用,它会捕抓dragstart
和selectstart
事件然后取消这些事件的默认行为并阻止它们冒泡。如果浏览器不支持选择事件,就在相应元素上添加 CSSuser-select: none
样式属性d3.dragEnable(window[, noclick])
该方法的作用与上一方法d3.dragDisable()
相反,它恢复了给定window
下的原生的 drag-and-drop 拖拽行为以及文本选中行为。
该方法会在鼠标释放时,即mouseup
事件被触发时调用。如果(可选)参数noclick
为true
则会临时阻止点击事件的触发,而且在 0 毫秒的延迟后取消该阻止行为(相当于只对当前的mouseup
事件可能触发的点击事件进行阻止,对后续的点击事件没有影响)
处理拖拽事件
使用方法 drag.on(typenames, [listener])
监听拖拽事件,并执行回调函数(一般是操作改变被拖拽元素的定位)
第一个参数 typenames
是需要监听的拖拽事件,D3 提供了 3 种拖拽相关事件类型:
start
当一个新的指针被激活时(在鼠标按下mousedown
或屏幕被点击touchstart
时)所触发的事件,表示拖拽开始drag
当一个激活的指针移动时(在鼠标移动mousemove
或在触屏上移动touchmove
时)所触发的事件,表示拖拽进行中end
当指针失活时(如松开鼠标按键mouseup
或从手指离开屏幕touchend
、touchcancel
时)所触发的事件,表示拖拽结束
提示
可以在事件后添加名称 name
并用 .
分隔,如 drag.on('drag.foo', listener)
,这样就可以为通过一个拖拽事件类型添加多个不同的处理函数
提示
如果希望移除拖拽相关事件的监听器,可以为将相应事件回调函数设置为 null
drag.on(".", null);
第二个参数 listener
是事件处理函数,它会在相应的拖拽类型事件触发时被调用,而且依次接收 2 个参数:
event
拖拽事件对象,该对象会暴露一些关于当前拖拽信息的属性:event.target
当前触发拖拽事件的拖拽器event.type
拖拽事件的类型,可以是start
、drag
、end
event.subject
拖拽目标event.x
和event.y
在拖拽过程中,拖拽目标的新坐标。该属性是指拖拽过程中不断跟新的event.dx
和event.dy
在一个完整的拖拽过程中,drag
事件不断触发,该属性记录了相邻两次分发的drag
事件之间,拖拽目标沿横轴和纵轴所移动的一小段距离。(对于start
和end
拖拽事件,这两个属性值都是0
)event.identifier
如果使用鼠标则该属性值为mouse
,如果使用触屏设备,则是一个表示触摸点的标识符event.active
当前同时激活的拖拽行为的数量(在start
和end
拖拽事件中,不包含这个属性)。提示
在多点触控设备中可能会存在同时执行有多个拖拽行为的情况,而它们会分发独立的拖拽事件,利用该属性,可以判断哪一个拖拽行为是第一个触发,哪一个拖拽行为是最后一个结束的,因为这两个特殊的拖拽行的
event.active
为0
。可以通过通过这个属性,间接判断多点触控设备多个并发的拖拽行为的开始和结束。event.sourceEvent
触发拖拽的原始事件,如mousemove
或touchmove
d
当前被拖拽的元素所绑定的数据 datum
而回调函数内的 this
指向当前的元素
提示
对于允许多点触控的屏幕,每一个激活的指针都会独立分发相应的拖拽事件,即使它们是同时激活的,但每一个指针可能拖拽的是不同的目标。
如果在一个拖拽事件进行中,使用 drag.on()
注册多一个事件处理函数,这个新增的事件处理函数并不会对当前的拖拽事件作出响应;可以使用当前拖拽事件对象的方法 event.on
临时注册一个拖拽事件处理函数,这个回到函数才会被当前的拖拽事件激活。
提示
方法 event.on
的作用和 drag.on()
类似,也是为特定的拖拽事件注册监听器,并执行相应的回调函数,但是它所注册的事件处理函数仅对当前拖拽事件有效
drag.on('start', started);
// 以下是拖拽事件 start 的回调函数
function started(event) {
// 为当前拖拽的圆添加 dragging 类名,可以使用该类设置一些拖拽元素特有的样式属性
const circle = d3.select(this).classed("dragging", true);
// 使用 event.on 临时添加一个拖拽事件 drag 和 end 的监听器
event.on("drag", dragged).on("end", ended);
// 拖拽事件 drag 的回调函数
function dragged(event, d) {
// 使用 selection.raise() 重新将选中的元素插入到页面,其作用是将拖拽元素提到「前景」来
// 并将圆的定位设置为当前指针拖拽的位置
circle.raise().attr("cx", d.x = event.x).attr("cy", d.y = event.y);
}
// 拖拽事件 end 的回调函数
function ended() {
// 移除 dragging 类名
circle.classed("dragging", false);
}
}