Chapter 4. Interaction and Transitions(交互与过渡)
(接上篇:《Getting Started with D3》填坑之旅(六):第三章(下))
首先热烈欢迎大家随我来到挖坑最密集的第四章:交互与过渡。经过前三章基本图形(列表、柱状图、散点图、折线图)的绘制,从本章开始介绍 D3.js
强大的页面交互与动画功能。
示例1:地铁各线路准点率一览表(第一版:交互)
本示例旨在提供一个简单的用户界面,以便用户通过页面交互,自行对比分析数据。
这里的准点率,定义为:同一线路、相邻两趟地铁、途经同一站点的实际等候时间,不超过预定间隔时间上浮 25% 的概率。纽约交管局会将相关数据按月汇总后向公众发布(不得不佩服,纽约当局在数据化管理工作方面的细致程度。本书于 2012 年出版。也就是说早在约十年以前,别人就能将地铁准点率的数据,以公共接口方式定期向公众发布;反观国内的地铁网,除了零星的几篇记者实测体验后刊登的几则新闻,似乎我们的地铁管理中心还没有这方面的数据治理意识,这就是差距)
闲言少叙。本例较前三章的基础图形更综合,结构也和之前的模板略有不同。为了兼顾美观,作者舍弃了一开始就画出一堆线条的方案,进而选择用户体验更好的交互式设计,将页面首分为左右两个区块:左侧为主绘图区,右侧为选项区。用户单击选项区的某条地铁线后,绘图区将动态呈现该线路各月的准点率折线。
1.1 构建更健壮的静态布局
根据这个设计,右侧的选项区,考虑每条地铁线路各占一行,每行左边为一个地铁线颜色标识块,后边为线路名称。左侧的主绘图区,调整为 更健壮的 视口布局,将绘图的核心元素(散点、折线……)统一放到 SVG 标签的直接子节点、分组元素 g
标签内(<g id="chart">
)上,以便从整体上更好地处理绘图区与坐标元素(刻度、标签)之间的间距,核心结构如下:
<div id="timeseries">
<svg>
<g id="chart"></g>
</svg>
</div>
<div id="key">
<div class="key_line">
<div class="key_square"></div>
<div class="key_label"></div>
</div>
<div class="key_line">
<div class="key_square"></div>
<div class="key_label"></div>
</div>
...
</div>
为了实现健壮的布局,JS 的绘图函数也做了相应调整,先是规定好三类尺寸:svg 容器尺寸、间隔尺寸(上下左右)、chart 主绘图区尺寸。这样 draw()
函数较前三章使用的硬编码间隔的方式更方便维护:
var container_dimensions = {width: 900, height: 400},
margins = {top: 10, right: 20, bottom: 30, left: 60},
chart_dimensions = {
width: container_dimensions.width - margins.left - margins.right,
height: container_dimensions.height - margins.top - margins.bottom
};
为了避免多次从 d3.select()
重复选中 chart
组,追加到 svg
内后,将其赋给一个变量:
var chart = d3.select('#timeseries')
.append('svg')
.attr('width', container_dimensions.width)
.attr('height', container_dimensions.height)
.append('g')
.attr('transform', 'translate('+margins.left+', '+margins.top+')')
.attr('id', 'chart');
接着,根据取值范围,设定 x 轴与 y 轴的比例尺,进而绘制出轴线元素:
var time_scale = d3.time.scale()
.range([0, chart_dimensions.width])
.domain([new Date(2009, 0, 1), new Date(2011, 3, 1)]);
var percent_scale = d3.scale.linear()
.range([chart_dimensions.height, 0])
.domain([65, 90]);
// draw axes
// x
var time_axis = d3.svg.axis().scale(time_scale);
chart.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0, '+chart_dimensions.height+')')
.call(time_axis);
// y
var count_axis = d3.svg.axis().scale(percent_scale).orient('left');
chart.append('g')
.attr('class', 'y axis')
.call(count_axis);
// y axis label
d3.select('.y.axis')
.append('text')
.attr('text-anchor', 'middle')
.text('percent on time')
.attr('transform', 'rotate(90, 0, 0)')
.attr('x', chart_dimensions.height / 2)
.attr('y', 50);
这里的实际取值范围,没有使用 d3.extent(),因为提前知道数据源的时间范围(2009 年到 2011 年一季度),以及准点率的大致范围(65% ~ 100%)。所谓的坑都是原书的笔误:第 3 行,起点应为 2009 年,写成了 2008 年;第 27 行,y 轴标签的水平位移量应该是 chart 容器高度的一半,书中写成了 svg 根容器高度。填坑后的效果如下:
接着绘制右侧的选项区。
选项区是各地铁线路的列表,要绑定的数据源,是统计汇总文件 subway_wait_mean.json
(此外还有统计详情文件 subway_wait.json
,用于绘制主绘图区的散点和折线,后文会用到)。subway_wait_mean.json
的元素结构如下:
{
line_id: "Line_1",
line_name: "1 Line",
mean: 75.60384615384615
}
从而可以按如下方式绑定:
// 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 });
效果如下:
这里的样式又和书中不同,选项区高度溢出了,需要加上修正样式 .key_line { margin-top: -2.25px; }
,具体取值根据实测情况酌情调整。调整后就一致了(其实标签的加载顺序还是不一致的,原谅我的强迫症。。。):
至此,示例一的静态部分就实现完毕了。这也是预想中的页面刚打开时的样子。接下来需要为选项区注册单击事件,实现实时展示对应折线图的目的。
1.2 添加交互逻辑
按照设计要求,单击某地铁线标签后,需要展示对应的准点率折线,这里需要用到刚刚提到的统计详情文件 subway_wait.json
,其元素结构如下:
{
"line_id": "Line_1",
"line_name": "1 Line",
"late_percent": 73.1,
"time": 1230786000000
}
注意:这里的时间 time
指的是纽约时间(西5区),与北京时间(东8区)相隔 13 个小时。也就是说,直接通过 new Date()
构造出的日期默认比纽约时间早 13 小时,如果需要精确还原时间轴,可以自行减掉 13 小时的毫秒数:13 * 3600 * 1000
。
作为相关背景知识,文中还理清了几个鼠标事件的执行先后顺序:从移动鼠标到某标签,单击标签,将先后触发 mouseover
、mousedown
、mouseup
、click
事件,本示例选择 mouseover
和 click
进行演示。
D3 提供的 on 方法可用于事件注册,接收的两个参数分别为事件名(eventName
)和事件处理函数(handler
),其中作回调的事件处理函数又接收两个参数,分别是当前绑定的数据项和当前的数据项的索引值,文中分别用 d
和 i
指代;函数内的 this
指向当前点击的页面元素(如果不清楚 this 的指向,可以在控制台检查 console.log(this)
的值。
单击事件的主要逻辑为:先根据元素 ID 值判定该折线在不在页面上——在页面上,则删掉;否则需从详情数据 JSON
中读取该线路信息,绘制到页面上。判定在于不在,D3 提供了一个工具函数 empty()
,这是 D3 选择某类元素后自带的方法,代码如下:
// out of draw function:
let json = null,
filtered_data = null;
// within draw function:
// Add interactions
key_items.on('click', get_timeseries_data);
function get_timeseries_data(d, i) {
// get the id of the current element
var id = d3.select(this).attr('id');
// see if we have an associated time series
var ts = d3.select('#' + id + '_path'); // e.g. #Line_A_path
if(ts.empty()) {
d3.json('/demos/data/subway_wait.json', function(data) {
filtered_data = data.filter(function(d) { return d.line_id === id});
draw_timeseries(filtered_data, id);
});
} else {
ts.remove();
}
}
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')
.attr('class', id.split('_')[1]);
g.append('path')
.attr('d', line(data))
}
为了突出主要逻辑,这里没有按文中所述在 draw()
方法外绑定单击事件,也就无需将 draw
中定义的比例尺提升至全局变量,只需要把获取的详情数据提到全局即可(第 16 行 filtered_data
)。然而这样设置后,单击选项区并没有折线显出来。用 F12 查看,单击时确实生成了对应的折线元素:
那为什么显示不出来捏?恭喜来到第一个坑:path 的 CSS 样式缺了一个线条颜色,正确的样式应该是:
path {
fill: none;
stroke: black; /* corrected */
}
之后就有动态效果了:
至此,示例一的交互逻辑就大功告成了。显然这样的效果只能是半成品:颜色全是黑色,也没法查看具体的数据点,线条一多密集恐惧症都得出来。想要更好的区分不同的地铁线路,提升用户体验,需要借助示例二中的新知识点——过渡。
1.3 D3.js 版本更迭
在进入示例 2 之前,先来看看新版 D3.js
库在写法上有什么调整吧,毕竟这本书已经快十岁高龄了,当年这张 v2.8.0
的旧船票,还能登上你的 v6.7.0
技术栈么?
答案显然是不行的——
- 比例尺 改进:还是老套路,
d3.scale.linear()
改为d3.scaleLinear()
;d3.time.scale()
改为d3.scaleTime()
; - 坐标轴 改进:x 轴改为
d3.axisBottom(time_scale)
;y 轴改为d3.axisLeft(percent_scale)
; - 数据绑定 改进:
data(data).enter().append()
改为data(data).join(enter => enter.append())
; - 数据读取 改进:
d3.json(url, fn)
改为d3.json(url).then(fn).catch(console.error)
,或使用async ... await
语法; - 事件绑定 改进:
D3Selection.on('click', fn)
中的回调函数,其参数由旧版的(d, index)
变为(event, d)
; - path 工具函数 改进:
d3.svg.line().x(mapFn1).y(mapFn2).interpolate('linear')
改为d3.line().x(mapFn1).y(mapFn2)
; - 箭头函数 改进:原来写在最后的
function fn(){...}
可以先调用再定义,转为const fn = () => {...}
后,必须先定义后调用; - this 问题:箭头函数是没有自己的
this
上下文的,因此可以直接传入当前数据项d
,绕开d3.select(this)
的坑。
解决了这些问题,新版 D3 库才能正常运行——
示例 1 完整代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
<title>Ch4 - Example 1 | Getting Started with D3</title>
</head>
<body>
<h2>Ch4 - Example 1 | A Subway Wait Assessment UI I—Interactions</h2>
<div id="timeseries"></div>
<div id="key"></div>
<script src="/demos/js/d3.js"></script>
<script>
let json = null,
filtered_data = null;
function draw(data) {
"use strict";
// script starts here
json = data;
var container_dimensions = {width: 900, height: 400},
margins = {top: 10, right: 20, bottom: 30, left: 60},
chart_dimensions = {
width: container_dimensions.width - margins.left - margins.right,
height: container_dimensions.height - margins.top - margins.bottom
};
var chart = d3.select('#timeseries')
.append('svg')
.attr('width', container_dimensions.width)
.attr('height', container_dimensions.height)
.append('g')
.attr('transform', 'translate('+margins.left+', '+margins.top+')')
.attr('id', 'chart');
var time_scale = d3.time.scale()
.domain([new Date(2009, 0, 1), new Date(2011, 3, 1)])
.range([0, chart_dimensions.width]);
var percent_scale = d3.scale.linear()
.domain([65, 90])
.range([chart_dimensions.height, 0]);
// draw axes
// x
var time_axis = d3.svg.axis().scale(time_scale);
chart.append('g')
.attr('class', 'x axis')
.attr('transform', 'translate(0, '+chart_dimensions.height+')')
.call(time_axis);
// y
var count_axis = d3.svg.axis().scale(percent_scale).orient('left');
chart.append('g')
.attr('class', 'y axis')
.call(count_axis);
// y axis label
d3.select('.y.axis')
.append('text')
.attr('text-anchor', 'middle')
.text('percent on time')
.attr('transform', 'rotate(90, 0, 0)')
.attr('x', chart_dimensions.height / 2)
.attr('y', 50);
// 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 });
// Add interactions
key_items.on('click', get_timeseries_data);
function get_timeseries_data(d, i) {
// get the id of the current element
var id = d3.select(this).attr('id');
// see if we have an associated time series
var ts = d3.select('#' + id + '_path'); // e.g. #Line_A_path
if(ts.empty()) {
d3.json('/demos/data/subway_wait.json', function(data) {
filtered_data = data.filter(function(d) { return d.line_id === id});
// console.log('filtered_data:', filtered_data);
draw_timeseries(filtered_data, id);
});
} else {
ts.remove();
}
}
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));
}
}
d3.json("/demos/data/subway_wait_mean.json", draw);
</script>
<style>
.axis path, line {
stroke: black;
}
.line {
float: left;
}
.line_container {
width: 150px;
height: 20px;
}
path {
fill: none;
stroke: black; /* corrected */
}
.key {
float: right;
}
.key_line {
font-size: 17px;
width: 100%;
margin-top: -2.25px;
}
.key_square {
height: 10px;
width: 10px;
outline: solid 1px black;
float: left;
margin: 6px 10px 0px 10px;
}
#timeseries {
float:left;
}
</style>
</body>
</html>
新版代码对比:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
<title>Ch4 - Example 1 | Getting Started with D3</title>
</head>
<body>
<h2>Ch4 - Example 1 | A Subway Wait Assessment UI I—Interactions</h2>
<div id="timeseries"></div>
<div id="key"></div>
<script src="/demos/js/d3.v6.js"></script>
<script>
let json = null;
function draw(data) {
"use strict";
// script starts here
json = data;
const container_dimensions = {width: 900, height: 400},
margins = {top: 10, right: 20, bottom: 30, left: 60},
chart_dimensions = {
width: container_dimensions.width - margins.left - margins.right,
height: container_dimensions.height - margins.top - margins.bottom
};
const chart = d3.select('#timeseries')
.append('svg')
.attr('width', container_dimensions.width)
.attr('height', container_dimensions.height)
.append('g')
.attr('transform', `translate(${margins.left}, ${margins.top})`)
.attr('id', 'chart');
const time_scale = d3.scaleTime()
.domain([new Date(2009, 0, 1), new Date(2011, 3, 1)])
.range([0, chart_dimensions.width]);
const percent_scale = d3.scaleLinear()
.domain([65, 90])
.range([chart_dimensions.height, 0]);
// draw axes
// x
const time_axis = d3.axisBottom(time_scale);
chart.append('g')
.attr('class', 'x axis')
.attr('transform', `translate(0, ${chart_dimensions.height})`)
.call(time_axis);
// y
const count_axis = d3.axisLeft(percent_scale);
chart.append('g')
.attr('class', 'y axis')
.call(count_axis);
// y axis label
d3.select('.y.axis')
.append('text')
.attr('text-anchor', 'middle')
.text('percent on time')
.attr('transform', 'rotate(90, 0, 0)')
.attr('x', chart_dimensions.height / 2)
.attr('y', 50);
// draw key items
const key_items = d3.select('#key')
.selectAll('div.key_line')
.data(data)
.join(enter => enter.append('div')
.attr('class', 'key_line')
.attr('id', d => d.line_id)
);
key_items.append('div')
.attr('class', 'key_square')
.attr('id', d => `key_square_${d.line_id}`);
key_items.append('div')
.attr('class', 'key_label')
.text(d => d.line_name);
// Add interactions
const draw_timeseries = (data, id) => {
const line = d3.line()
.x(d => time_scale(d.time))
.y(d => percent_scale(d.late_percent));
const g = d3.select('#chart')
.append('g')
.attr('id', `${id}_path`) // Line_4_path
.attr('class', id.split('_')[1]); // 4
g.append('path')
.attr('d', line(data));
}
const get_timeseries_data = async (ev, d) => {
// get the id of the current element
const {line_id: id} = d;
// see if we have an associated time series
const ts = d3.select(`#${id}_path`); // e.g. #Line_A_path
if(ts.empty()) {
const data = await d3.json('/demos/data/subway_wait.json');
const filtered_data = data.filter(d => d.line_id === id);
draw_timeseries(filtered_data, id);
} else {
ts.remove();
}
}
key_items.on('click', get_timeseries_data);
}
d3.json("/demos/data/subway_wait_mean.json").then(draw)
.catch(console.error);
</script>
<style>
.axis path, line {
stroke: black;
}
.line {
float: left;
}
.line_container {
width: 150px;
height: 20px;
}
path {
fill: none;
stroke: black; /* corrected */
}
.key {
float: right;
}
.key_line {
font-size: 17px;
width: 100%;
margin-top: -2.25px;
}
.key_square {
height: 10px;
width: 10px;
outline: solid 1px black;
float: left;
margin: 6px 10px 0px 10px;
}
#timeseries {
float:left;
}
</style>
</body>
</html>