《Getting Started with D3》填坑之旅(八):第四章(下)

cover

Chapter 4. Interaction and Transitions(交互与过渡)

(接上篇:《Getting Started with D3》填坑之旅(七):第四章(上)

在上一篇完成的交互式折线图中,存在明显的用户体验问题:颜色单一导致的多条地铁线数据重合、无法查看具体某个点的数据、由于线条辨识度低而无法隐藏某条线路、缺乏相应的入场动画特效……这些问题,都可以通过 D3 的过渡功能轻松实现——

示例2:地铁各线路准点率一览表(第二版:过渡)

这里的 过渡,是指页面元素的状态(即属性集),从一种状态平滑切换的另一种状态的效果。文中列举了 过渡 效果的四个用途:

  1. 突出重点数据(尤其是涉及时间的场景);
  2. 提示出可供单击等操作的地方;
  3. 跟踪可视化数据中的常量;
  4. 实现酷炫的动画。

本例将实现的过渡效果有:

  1. 给折线添加数据点,并实现鼠标悬停放大的效果;
  2. 鼠标悬停时,显示该数据详情的动态标签;
  3. 为折线添加进场动画特效;
  4. 在进场动画后添加地铁线标签。

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.tooltipmouseout.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,返回值是一个关于 di 的表达式,表示当前数据点的延迟时长:

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 在文中也没有出现过。。。很显然,这个丢失的 .timeseriesd.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 上维护,敬请关注并多提宝贵意见,先谢谢了!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

安冬的码畜日常

您的鼓励是我持续优质内容的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值