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

cover

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

作为相关背景知识,文中还理清了几个鼠标事件的执行先后顺序:从移动鼠标到某标签,单击标签,将先后触发 mouseovermousedownmouseupclick 事件,本示例选择 mouseoverclick 进行演示。

D3 提供的 on 方法可用于事件注册,接收的两个参数分别为事件名(eventName)和事件处理函数(handler),其中作回调的事件处理函数又接收两个参数,分别是当前绑定的数据项和当前的数据项的索引值,文中分别用 di 指代;函数内的 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 技术栈么?

答案显然是不行的——

  1. 比例尺 改进:还是老套路,d3.scale.linear() 改为 d3.scaleLinear()d3.time.scale() 改为 d3.scaleTime()
  2. 坐标轴 改进:x 轴改为 d3.axisBottom(time_scale);y 轴改为 d3.axisLeft(percent_scale)
  3. 数据绑定 改进:data(data).enter().append() 改为 data(data).join(enter => enter.append())
  4. 数据读取 改进:d3.json(url, fn) 改为 d3.json(url).then(fn).catch(console.error),或使用 async ... await 语法;
  5. 事件绑定 改进:D3Selection.on('click', fn) 中的回调函数,其参数由旧版的 (d, index) 变为 (event, d)
  6. path 工具函数 改进:d3.svg.line().x(mapFn1).y(mapFn2).interpolate('linear') 改为 d3.line().x(mapFn1).y(mapFn2)
  7. 箭头函数 改进:原来写在最后的 function fn(){...} 可以先调用再定义,转为 const fn = () => {...} 后,必须先定义后调用;
  8. 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>
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

安冬的码畜日常

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

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

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

打赏作者

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

抵扣说明:

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

余额充值