Chapter 4. Interaction and Transitions(交互与过渡)
(接上篇:《Getting Started with D3》填坑之旅(七):第四章(上))
在上一篇完成的交互式折线图中,存在明显的用户体验问题:颜色单一导致的多条地铁线数据重合、无法查看具体某个点的数据、由于线条辨识度低而无法隐藏某条线路、缺乏相应的入场动画特效……这些问题,都可以通过 D3 的过渡功能轻松实现——
示例2:地铁各线路准点率一览表(第二版:过渡)
这里的 过渡,是指页面元素的状态(即属性集),从一种状态平滑切换的另一种状态的效果。文中列举了 过渡 效果的四个用途:
- 突出重点数据(尤其是涉及时间的场景);
- 提示出可供单击等操作的地方;
- 跟踪可视化数据中的常量;
- 实现酷炫的动画。
本例将实现的过渡效果有:
- 给折线添加数据点,并实现鼠标悬停放大的效果;
- 鼠标悬停时,显示该数据详情的动态标签;
- 为折线添加进场动画特效;
- 在进场动画后添加地铁线标签。
1. 鼠标悬停时放大数据点
这里的数据点,就是绘制折线时新增的散点圆圈(<circle>
),上例中实现折线调用的函数是:
function draw_timeseries(data, id) {
var line = d3.svg.line()
.x(function(d) { return time_scale(d.time) })
.y(function(d) { return percent_scale(d.late_percent) })
.interpolate('linear');
var g = chart.append('g')
.attr('id', id + '_path') // Line_4_path
.attr('class', id.split('_')[1]); // 4
g.append('path')
.attr('d', line(data));
}
要新增散点,假设初始半径为 5,需在第 10 行后添加如下代码:
g.selectAll('circle')
.data(data)
.enter()
.append('circle')
.attr('cx', function(d){ return time_scale(d.time) })
.attr('cy', function(d){ return percent_scale(d.late_percent) })
.attr('r', 5);
鼠标悬停效果需要注册两个鼠标事件逻辑:鼠标移入、鼠标移出。D3 的过渡是通过选中元素的 transition()
方法实现的,其后可以加上各种终止效果:
g.selectAll('circle')
.on('mouseover', function(d) {
d3.select(this).transition()
.attr('r', 9); // enlargment
})
.on('mouseout', function(d) {
d3.select(this).transition()
.attr('r', 5); // resume
});
看看效果,点击 4 号线后,鼠标悬停到 2010 年 5 月的散点上——
2. 添加数据详情标签
实现了悬停放大效果,再来添加悬停提示标签,展示该详情数据。文本标签使用 <text>
,最终效果是鼠标悬停时到该点时显示,移开后自然淡出并隐藏。这里需要在同一个事件上注册多个逻辑,需要使用一个事件标签(如 mouseover.tooltip
、mouseout.tooltip
)。接着刚刚的事件注册部分写:
g.selectAll('circle')
.on('mouseover', function(d) {
d3.select(this).transition().attr('r', 9);
})
.on('mouseout', function(d) {
d3.select(this).transition().attr('r', 5);
})
// show / hide detail datum
.on('mouseover.tooltip', function(d) {
d3.select('text#' + d.line_id).remove();
d3.select('#chart')
.append('text')
.attr('id', d.line_id)
.text(d.late_percent + '%')
.attr('x', time_scale(d.time) + 10)
.attr('y', percent_scale(d.late_percent) - 10)
})
.on('mouseout.tooltip', function(d) {
d3.select('text#' + d.line_id)
.transition()
.duration(500)
.style('opacity', 0)
.attr('transform', 'translate(10, -10)')
.remove();
});
注意第 10 行的写法,在渲染标签之前先做清空,否则在淡出效果结束前,一旦鼠标快速悬停到其他散点,将会产生 id
值相同的文本标签(d.line_id
),从而打断之前元素的正常淡出。既然如此,是不是 id 不重复就行了呢?我还真试了试。把 id 变成 id_index
的形式:
.on('mouseover.tooltip', function(d, i) {
// d3.select('text#' + d.line_id).remove();
d3.select('#chart')
.append('text')
.attr('id', [d.line_id, i].join('_')) // new id pattern: id_index
.text(d.late_percent + '%')
.attr('x', time_scale(d.time) + 10)
.attr('y', percent_scale(d.late_percent) - 10)
})
.on('mouseout.tooltip', function(d, i) {
d3.selectAll('text#' + [d.line_id, i].join('_')) // new id pattern: id_index
.transition()
.duration(500)
.style('opacity', 0)
.attr('transform', 'translate(10, -10)')
.remove();
});
注意第 11 行,除了改 id 值,还要改选中方式,因为此时页面上允许出现多个详情标签,尤其是当手抖或发鸡爪疯时,鼠标反复悬停、移出同一个散点,将会产生多个 id 值依旧会大量重复的文本标签(总不至于还要再去统计鼠标悬停到各个散点的次数吧,跑偏了)——
这样不得不选中所有的、id 形如 id_index
的本文标签(d3.selectAll()
)。对比一下,明明 一行 就能避免的问题,这里至少要做 五处 改动,才能勉强达到不报错的地步(重复 id 的问题依旧存在);有了那关键的一行初始化清理工作,不管鼠标手怎么发疯,页面上始终只有一个数据标签,而且不会出现刚刚提到的所有问题!所以后来的小伙伴们,不要再挣扎了,此坑已填。
填上刚刚为了深入理解防御式编程自己给自己挖的坑(作者提到了不这样做的问题,只是没像我这样展开)后,再来看一个作者没提到的坑。F12 打开控制台,当鼠标移出某个散点时,控制台报错了:
说的是旧版 D3.js
库中,某一行的 g
标签属性 transform
报了一个为空的错误。单击右边的链接跟踪下去,发现是源码中的这一行有问题:
显然,第 3924 行的 a
在鼠标移出时为空,但这里没给缺省值。既然没给,就找到源码,在 a
后面加上 ||''
:
刷新再试,搞定(这个不同贴图了吧。。。算大坑不?初学 D3.js 顺便把旧版库源码的八阿哥一并修理了,作者真是用心良苦啊。。。)
3. 添加进场动画特效
所谓的进场动画,就是鼠标每点出一条准点率折线,立即显示该折线,同时线上各个散点在随后指定的一段时间内(如 1 秒内)从左至右依次绘制到折线上。这里需要用到 D3 的延迟函数 .delay(mapFn)
,参数 mapFn
是一个映射函数,其参数为当前数据 d
与当前索引值 i
,返回值是一个关于 d
、i
的表达式,表示当前数据点的延迟时长:
g.selectAll('circle')
.transition()
.delay(function(d, i){ return i / data.length * 1000 })
.attr('r', 5);
将这段代码加到刚开始绘制散点的后面,记得将初始大小改为 0,否则看不到过渡效果:
g.selectAll('circle')
.data(data)
.enter()
.append('circle')
.attr('cx', function(d){ return time_scale(d.time) })
.attr('cy', function(d){ return percent_scale(d.late_percent) })
.attr('r', 0);
// Entry animation:
g.selectAll('circle')
.transition()
.delay(function(d, i){ return i / data.length * 1000 })
.attr('r', 5);
或者直接:
g.selectAll('circle')
.data(data)
.enter()
.append('circle')
.attr('cx', function(d){ return time_scale(d.time) })
.attr('cy', function(d){ return percent_scale(d.late_percent) })
.attr('r', 0)
.transition() // Entry animation
.delay(function(d, i){ return i / data.length * 1000 })
.attr('r', 5);
效果如下:
4. 添加地铁线路标签
现在来处理不同线路不易区分的问题。虽然可以借助颜色标识地铁线,但线路一多,尤其是颜色相近的线路,难道还要让人去比较吗?万一看的人刚好有色盲呢?因此这里预想的效果,是在入场动画结束后,在最后一个散点上直接标出这条线是哪条地铁线路。
这里又要用到 D3 一个新方法 .each('end', fn)
,用来控制每一个过渡效果在执行末尾时需要实现的逻辑,这里就能对最后一个散点圆单独设置状态:
g.selectAll('circle')
.transition()
.delay(function(d, i){ return i / data.length * 1000 })
.attr('r', 5)
.each('end', function(d, i){
if(i === data.length - 1) {
add_label(this, d);
}
});
function add_label(circle, d) {
// enlargement
d3.select(circle)
.transition()
.attr('r', 9);
// labeling
g.append('text')
.text(d.line_id.split('_')[1]) // id: Line_4 -> 4
.attr('x', time_scale(d.time))
.attr('y', percent_scale(d.late_percent))
.attr('dy', '0.35em')
.attr('class', 'linelabel')
.attr('text-anchor', 'middle')
.style('opacity', 0)
.style('fill', 'white')
.transition()
.style('opacity', 1);
}
效果如下:(继续填坑:实际的数据源中没有原书中的 V 号线。。。用 R 号线代替吧)
5. 给不同线路上色
不知道是不是前面四个内容让作者讲述得有点飘飘然,样式美化的工作放到了最后来做(不然我可能真的怀疑作者是一个色盲)。好在纽约各地铁线的颜色已经有现成的值了,引入素材包内的 train_colours.css
文件即可:
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="train_colours.css">
<title>Ch4 - Example 2 | Getting Started with D3</title>
</head>
然后按照文中所述补充如下样式:
.timeseries path {
stroke-width: 3px;
}
.timeseries circle {
stroke: white;
}
.timeseries text {
fill: white;
stroke: null;
font-size: 12px;
font-weight: bold;
}
依次点击 N L 3 G M A 5(是的,强迫症如我专门研究过书上那些线的单击顺序)效果如下:
。。。说好的效果呢?不应该是这样吗——
欢迎来到本例、本章最后的彩蛋大坑——上古巨坑之 CSS 上色。。。。(作者你出来,我们好好聊聊吧)
先来看引入的 CSS 文件内的样式,全是 CSS 样式类,类名就是详情数据中每条线对应的 d.line_id
:
.Line_A, .Line_C, .Line_E{
stroke:#2850AD;
fill:#2850AD;
background-color:#2850AD;
}
.Line_B, .Line_D, .Line_F, .Line_M {
stroke:#FF6319;
fill:#FF6319;
background-color:#FF6319;
}
但是书中根本没有将 d.line_id
赋给一个样式类的操作,指定 class
属性的地方用的是 id.split('_')[1]
:
function draw_timeseries(data, id) {
var line = d3.svg.line()
.x(function(d) { return time_scale(d.time) })
.y(function(d) { return percent_scale(d.late_percent) })
.interpolate('linear');
var g = chart.append('g')
.attr('id', id + '_path') // Line_4_path
.attr('class', id.split('_')[1]); // 4
// ...
}
而且,补充的三个样式,前缀 .timeseries
在文中也没有出现过。。。很显然,这个丢失的 .timeseries
和 d.line_id
应该放到详情折线的元素里:
function draw_timeseries(data, id) {
var line = d3.svg.line()
.x(function(d) { return time_scale(d.time) })
.y(function(d) { return percent_scale(d.late_percent) })
.interpolate('linear');
var g = chart.append('g')
.attr('id', id + '_path') // Line_4_path
// .attr('class', id.split('_')[1]); // 4
.attr('class', 'timeseries ' + id); // 4
// ...
}
再来一遍 N L 3 G M A 5:
不愧是章节终极大坑,折线的颜色还没出来。F12查看一条折线一探究竟:
原来是示例一种为了让折线显示出来修复的Bug,这里 stroke: black;
的优先级比外链样式表的高,必须禁用掉:
path {
fill: none;
/* Solve the conflict with 'train_colours.css': */
/* stroke: black; */
}
再来一遍 N L 3 G M A 5:
主绘图区搞定!最后来看标签部分的样式是什么坑。
书中提到:
Hopefully you noticed that we set each line’s group class, and each key_square’s class, to be compatible with stylesheet so simply by including it we get a marked improvement in the look of the UI.
希望你能注意到,之前设置的每个折线组的 key_square 样式类,要与引入 CSS 文件的样式相匹配。因此,只需要简单引入它(新样式类 d.line_id)就能显著改善示例 UI 的外观……
好吧,找到设置初始布局时指定 key_square
类的位置:
// draw key items
var key_items = d3.select('#key')
.selectAll('div.key_line')
.data(data)
.enter()
.append('div')
.attr('class', 'key_line')
.attr('id', function(d) { return d.line_id });
key_items.append('div')
.attr('class', 'key_square')
.attr('id', function(d){ return 'key_square_' + d.line_id });
key_items.append('div')
.attr('class', 'key_label')
.text(function(d) { return d.line_name });
注意第 11 行,定义小方块的类名时,只给了一个 key_square
,实际上需要加上每条线路的 line_id
值:
key_items.append('div')
// .attr('class', 'key_square')
.attr('class', function(d){ return 'key_square ' + d.line_id }) // add line_id accordingly
.attr('id', function(d){ return 'key_square_' + d.line_id });
再 N L 3 G M A 5 一把:
完了吗?不,还有最后一个隐藏的坑没填上——弹出的详情文本标签上没有上色:
对比最终的素材包,弹出标签的颜色也和地铁线路颜色保持一致。只需找到定义弹出标签的地方,加上线路标识类:
// ...
.on('mouseover.tooltip', function(d, i) {
d3.select('text#' + d.line_id).remove();
d3.select('#chart')
.append('text')
.attr('id', d.line_id)
.text(d.late_percent + '%')
.attr('x', time_scale(d.time) + 10)
.attr('y', percent_scale(d.late_percent) - 10)
})
// ...
在第 6 行的后面追加一行即可:
// ...
.on('mouseover.tooltip', function(d, i) {
d3.select('text#' + d.line_id).remove();
d3.select('#chart')
.append('text')
.attr('id', d.line_id)
.attr('class', d.line_id) // add color to tooltip
.text(d.late_percent + '%')
.attr('x', time_scale(d.time) + 10)
.attr('y', percent_scale(d.late_percent) - 10)
})
// ...
最后再深情吟唱一遍本章专属摩斯密码 N L 3 G M A 5:
大功告成!!!
P.S.: 关于新旧版本的异同比较,鉴于上一篇引入源码时发现比较浪费空间,新版 v6.7.0 的源码就不粘出来了,后面我会将书中讲到的示例代码统一放到 GitHub 上维护,敬请关注并多提宝贵意见,先谢谢了!