JavaScript-数据可视化-全-

JavaScript 数据可视化(全)

原文:zh.annas-archive.org/md5/4ff2cd12821f1d4bb3dde39d128435c4

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

我们越来越无法忽视数据在我们生活中的重要性。数据对人类历史上最大的社会组织至关重要(如 Facebook 和 Google 这样的巨头),而它的收集也具有广泛的地缘政治影响,正如我们在 NSA 监控丑闻中看到的那样。但同样,我们也越来越容易忽视数据本身。一个估计表明,我们系统收集的 99.5% 的数据都被浪费了。

数据可视化是解决这一空白的工具。有效的可视化能够澄清问题;它们将抽象的数字集合转化为形状和形式,让观众迅速理解并掌握。这些最佳的可视化能够直观地传达这种理解。观众能够立即理解数据——无需深思。这使得观众能够更充分地考虑数据的含义:它讲述的故事,它揭示的洞察,甚至是它所提供的警告。

如果你今天正在开发网站或 web 应用程序,那么你很可能需要传达数据——这些数据最适合通过良好的可视化呈现。那么,你怎么知道什么样的可视化是合适的呢?更重要的是,如何真正创建一个可视化?在接下来的章节中,我们将探讨数十种不同的可视化方法、技术和工具包。每个例子都会讨论可视化的适用性(并提供可能的替代方案),并提供逐步的指导,帮助你将可视化添加到网页中。

本书的理念

在编写本书时,我尝试遵循四个主要原则,以确保它能提供有意义且实用的指导。

  • 实现与设计

    本书不会教你如何设计数据可视化。老实说,其他一些作者在这方面比我更有资格(比如爱德华·塔夫特)。相反,本书将专注于如何实现可视化。在适当的情况下,我会从更宏观的角度讨论某些可视化策略的优缺点,但主要目标是向你展示如何创建各种可视化。(我知道有时候老板坚决要求做饼图。)

  • 代码与样式

    正如你从标题中猜到的,这本书专注于如何使用 JavaScript 代码创建可视化。例子并不假设你是 JavaScript 专家——如果遇到比基本 jQuery 选择器更复杂的代码,我会确保为你解释清楚——但是我不会花太多时间讨论可视化的样式。幸运的是,样式化可视化与样式化其他 web 内容基本相同。对 HTML 和 CSS 的基本了解将有助于你将可视化添加到网页中。

  • 简单与复杂

    书中的大多数示例都是简单、直接的可视化。复杂的可视化可能具有吸引力和说服力,但学习大量的高级代码通常不是学习这项技能的最佳方式。在这些示例中,我会尽量保持简单,这样你就能清晰地看到如何使用各种工具和技术。然而,简单并不意味着无聊,甚至最简单的可视化也可以启发人心、激发灵感。

  • 现实与理想世界的对比

    当你开始构建自己的可视化时,你会发现现实世界通常并不像你希望的那样友好。开源库可能有 bug,第三方服务器可能存在安全问题,并且并非每个用户都更新到了最新的 Web 浏览器。我在本书的示例中已经解决了这些现实问题。我会向你展示如何在实践中兼容旧版浏览器,如何遵守诸如跨域资源共享(CORS)等安全约束,以及如何绕过他人代码中的 bug。

书籍目录

接下来的章节涵盖了各种可视化技术以及我们可以用来实现它们的 JavaScript 库。

  • 第一章 从最基本的可视化开始——使用 Flotr2 库创建静态图表和绘图。

  • 第二章 为可视化添加了交互性,用户可以选择内容、放大查看和跟踪值。本章还展示了如何直接从 Web 获取可视化所需的数据。为了多样化,其示例使用了基于 jQuery 的 Flot 库。

  • 第三章 讨论了如何在网页中整合多个可视化和其他内容;它使用了 jQuery sparklines 库。

  • 第四章 中,我们考虑了除标准图表和绘图外的其他可视化类型,包括树形图、热图、网络图和词云。每个示例都专注于一个特定的 JavaScript 库,该库专为该类型的可视化而设计。

  • 第五章 涉及基于时间的可视化。本章探讨了几种可视化时间轴的方法,包括传统库、纯 HTML、CSS 和 JavaScript,以及功能全面的 Web 组件。

  • 第六章 中,我们讨论了地理数据,并探讨了将地图整合到可视化中的不同方法。

  • 第七章 介绍了强大的 D3.js 库,这是一个灵活且功能全面的工具包,用于构建几乎任何类型的自定义可视化。

  • 第八章开始,我们将探讨基于 Web 的可视化的其他方面。本章展示了 Underscore.js 库,它使得准备驱动我们可视化的数据变得更加容易。

  • 最后,第九章第十章 详细讲解了一个完整的单页 Web 应用程序的开发,该应用程序依赖于数据可视化。在这里,我们将看到如何使用现代开发工具,如 Yeoman 和 Backbone.js 库。

示例的源代码

为了使文本尽可能清晰易读,示例通常包含孤立的 JavaScript 代码片段,以及偶尔的 HTML 或 CSS 片段。所有示例的完整源代码可以在 GitHub 上找到,网址是 jsDataV.is/source/

第一章 图表数据

许多人认为数据可视化是复杂的、交互性强的图形,充满了令人眼花缭乱的复杂性。然而,创建有效的可视化并不需要毕加索的艺术技能或图灵的编程专长。事实上,当你考虑数据可视化的最终目的——帮助用户理解数据时,简洁性是有效可视化最重要的特性之一。简单、直观的图表往往是最容易理解的。

毕竟,用户已经看过成百上千个条形图、折线图、X/Y 图等。他们了解这些图表背后的惯例,因此可以毫不费力地解读设计良好的图表示例。如果一个简单、静态的图表最好地呈现数据,就使用它。你将花费更少的精力来创建可视化,而你的用户也将花费更少的精力去理解它。

有许多高质量的工具和库可以帮助你开始制作简单的可视化。通过这些工具,你可以避免重复发明轮子,并且通过使用库的默认设置,确保呈现出相对吸引人的效果。我们将在本书中介绍这些工具中的一些,但在这一章我们将使用 Flotr2 库(www.humblesoftware.com/flotr2/)。Flotr2 使得将标准的条形图、折线图和饼图添加到任何网页变得简单,它还支持一些不太常见的图表类型。我们将在接下来的例子中详细介绍这些技术。你将学到以下内容:

  • 如何创建一个基本的条形图

  • 如何用折线图绘制连续数据

  • 如何用饼图突出显示比例

  • 如何用散点图绘制 X/Y 数据

  • 如何用气泡图显示 X/Y 数据的大小

  • 如何用雷达图显示多维数据

创建基本的条形图

如果你对哪种类型的图表最能解释你的数据感到疑惑,首先考虑的可能应该是基本的条形图。我们常常看到条形图,以至于容易忽视它们的有效性。条形图可以显示一个值随时间的变化,或者提供多个值的直接比较。让我们逐步了解如何构建一个条形图。

步骤 1:包含所需的 JavaScript

因为我们使用 Flotr2 库来创建图表,所以需要在我们的网页中包含该库。Flotr2 包目前还不够流行,无法通过公共内容分发网络使用,因此你需要下载一个副本并将其托管在自己的 web 服务器上。我们将使用最小化版本(flotr2.min.js),因为它提供最佳性能。

Flotr2 不需要任何其他 JavaScript 库(如 jQuery),但它依赖于 HTML canvas 特性。现代主流浏览器(如 Safari、Chrome、Firefox)都支持 canvas,但直到 IE9 版本,Internet Explorer(IE)都不支持该特性。不幸的是,仍然有大量用户使用 IE8(甚至更早版本)。为了支持这些用户,我们可以在页面中包含一个额外的库(excanvas.min.js)。该库可以通过 Google 获取 (code.google.com/p/explorercanvas/)。从以下 HTML 骨架开始创建你的文档:

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
     **</head>**
     **<body>**
       *<!-- Page Content Here -->*
➊     *<!--[if lt IE 9]><script src="js/excanvas.min.js"></script><![endif]-->*
       **<script** src="js/flotr2.min.js"**></script>**
     **</body>**
   **</html>**

由于其他浏览器不需要 excanvas.min.js,我们在 ➊ 使用了一些特殊的标记,确保只有 IE8 及更早版本会加载它。同时,请注意,我们将 JavaScript 库放在文档的末尾进行加载。这种做法可以让浏览器在等待服务器提供 JavaScript 库的同时,先加载完整的 HTML 标记并开始布局页面。

第 2 步:预留一个 <div> 元素来承载图表

在文档中,我们需要创建一个 <div> 元素来包含图表。这个元素必须有明确的高度和宽度,否则 Flotr2 将无法构建图表。我们可以通过 CSS 样式表来指定元素的大小,或者直接在元素本身上设置。下面是使用后一种方法时,文档的可能样式。

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>**
    **<meta** charset="utf-8"**>**
    **<title></title>**
  **</head>**
  **<body>**
    **<div** id="chart" style="width:600px;height:300px;"**></div>**
    *<!--[if lt IE 9]><script src="js/excanvas.min.js"></script><![endif]-->*
    **<script** src="js/flotr2.min.js"**></script>**
  **</body>**
**</html>**

请注意,我们已经给 <div> 元素指定了明确的 id"chart"),以便稍后引用。你需要使用一个基本的模板(导入 Flotr2 库并设置 <div>)来为本章中的所有图表进行设置。

第 3 步:定义数据

现在我们可以处理要显示的数据了。在这个示例中,我将使用过去七年中曼城队在英格兰英超联赛中的胜场数。当然,你需要用实际的数据值替换它们,可以通过内联 JavaScript(如下示例)或其他方式(如通过 AJAX 调用服务器)来实现。

**<script>**
**var** wins = [[[2006,13],[2007,11],[2008,15],[2009,15],[2010,18],[2011,21],
             [2012,28]]];
</script>

如你所见,我们有三层数组结构。让我们从内层开始,一层一层地分析。对于 Flotr2 图表,每个数据点都以一个包含 x 值和 y 值的二元数组表示。在我们的例子中,我们使用年份作为 x 值,胜场数作为 y 值。我们将所有这些值收集到另一个名为 series 的数组中。然后我们将这个 series 放入一个更外层的数组中。我们可以在这个外层数组中输入多个 series,但现在我们只展示一个 series。下面是对每一层的简要总结:

  • 每个数据点由一个 x 值和一个 y 值组成,并以数组的形式包装。

  • 每个 series 由一组数据点组成,并以数组的形式包装。

  • 用于绘制图表的数据由一个或多个以数组形式包装的 series 组成。

第 4 步:绘制图表

这就是我们所需要的全部设置。只需简单调用 Flotr2 库,如下所示,就能创建我们第一次尝试的图表。

window.onload = **function** () {
    Flotr.draw(
        document.getElementById("chart"),
        wins,
        {
           bars: {
               show: **true**
           }
        }
    );
};

首先,我们确保浏览器已经加载了文档;否则,chart <div> 可能不存在。这就是 window.onload 的作用。一旦事件发生,我们使用三个参数调用 Flotr.draw:包含图表的 HTML 元素、图表的数据和任何图表选项(在这种情况下,我们只指定选项,以告诉 Flotr2 从数据中创建一个条形图)。

由于 Flotr2 不需要 jQuery,我们在这个例子中没有利用 jQuery 的任何快捷方式。如果你的页面已经包含了 jQuery,你可以在本章中使用标准的 jQuery 约定,确保在窗口加载后执行脚本,并找到图表的 <div> 容器。

图 1-1 展示了你在网页上看到的内容。

Flotr2 库将数据转化为一个基本的(虽然未修饰的)条形图。图 1-1. Flotr2 库将数据转化为一个基本的(虽然未修饰的)条形图。

现在你有了一个条形图,但它显示信息的效果并不好。让我们逐步添加一些选项,直到得到我们想要的效果。

步骤 5:修正垂直轴

垂直轴最明显的问题是其刻度。默认情况下,Flotr2 会根据数据中的最小值和最大值自动计算轴的范围。在我们的例子中,最小值是 2007 年的 11 场胜利,所以 Flotr2 会忠实地将其作为 y 轴的最小值。然而,在条形图中,通常最好将 0 设置为 y 轴的最小值。如果不使用 0,你就可能过度强调数值之间的差异,给用户造成困惑。举个例子,任何快速浏览图 1-1 的人,可能会认为曼城在 2007 年没有赢得任何比赛。那显然不会对球队产生正面影响。

另一个与垂直轴相关的问题是格式化。Flotr2 默认精确到小数点后一位,所以它会给所有标签添加多余的“.0”。我们可以通过指定一些 y 轴选项来解决这两个问题。

Flotr.draw(document.getElementById("chart"), [wins], {
    bars: {
        show: **true**
    },
    yaxis: {
        min: 0,
        tickDecimals: 0
    }
});

min 属性设置 y 轴的最小值,tickDecimals 属性告诉 Flotr2 在标签中显示多少位小数。在我们的例子中,我们不希望显示小数位。

正如你在图 1-2 中看到的,添加这些选项确实改善了垂直轴,因为现在数值从零开始,并且格式适用于整数。

简单的选项帮助 Flotr2 构建更好的垂直轴。图 1-2. 简单的选项帮助 Flotr2 构建更好的垂直轴。

步骤 6:修正横轴

水平轴也需要一些调整。与 y 轴一样,Flotr2 假定 x 轴值是实数,并在标签中显示一位小数。由于我们绘制的是年份,完全可以像处理 y 轴一样将精度设置为 0。但是,这并不是一个通用的解决方案,因为当 x 值是非数字类别(例如团队名称)时,这个方法就不起作用。为了处理更一般的情况,我们首先将数据更改为使用简单的数字而不是年份作为 x 值。然后,我们将创建一个数组,将这些简单的数字映射到任意字符串,作为标签使用。

**var** wins = [[[0,13],[1,11],[2,15],[3,15],[4,18],[5,21],[6,28]]];
**var** years = [
    [0, "2006"],
    [1, "2007"],
    [2, "2008"],
    [3, "2009"],
    [4, "2010"],
    [5, "2011"],
    [6, "2012"]
];

如你所见,x 值不再使用实际的年份,而是简单地使用 0、1、2,依此类推。然后我们定义了一个第二个数组,将这些整数值映射到字符串。虽然这里我们的字符串是年份(因此是数字),但它们可以是任何东西。

另一个问题是条形之间缺乏间距。默认情况下,每个条形占据它的全部水平空间,但这使得图表看起来非常拥挤。我们可以通过barWidth属性来调整这一点。我们将其设置为0.5,使每个条形仅占用一半的可用空间。

下面是我们如何将这些选项传递给 Flotr2。

   Flotr.draw(document.getElementById("chart"), wins, {
       bars: {
           show: **true**,
           barWidth: 0.5
       },
       yaxis: {
           min: 0,
           tickDecimals: 0
       },
       xaxis: {
➊         ticks: years
       }
   });

请注意➊,我们使用 x 轴的ticks属性来告诉 Flotr2 哪些标签对应哪些 x 值。现在我们的图表开始有了进展,如图 1-3 所示。x 轴标签适用于年份,并且条形之间有间隔,以提高图表的可读性。

我们可以为水平轴定义自己的标签。图 1-3. 我们可以为水平轴定义自己的标签。

步骤 7:调整样式

现在图表功能齐全且可读,我们可以开始关注美学方面的调整。让我们添加标题,去除不必要的网格线,并调整条形的颜色。

Flotr.draw(document.getElementById("chart"), wins, {
    title: "Manchester City Wins",
    colors: ["#89AFD2"],
    bars: {
        show: **true**,
        barWidth: 0.5,
        shadowSize: 0,
        fillOpacity: 1,
        lineWidth: 0
    },
    yaxis: {
        min: 0,
        tickDecimals: 0
    },
    xaxis: {
        ticks: years
    },
    grid: {
        horizontalLines: **false**,
        verticalLines: **false**
    }
});

如图 1-4 所示,现在我们有了一张曼城球迷可以骄傲的条形图。

其他选项让我们调整图表的视觉样式。图 1-4. 其他选项让我们调整图表的视觉样式。

对于任何中等大小的数据集,标准条形图通常是最有效的可视化方式。用户已经熟悉其惯例,因此不需要额外的精力去理解格式。条形本身与背景形成了清晰的视觉对比,并使用一个线性维度(高度)来显示数值之间的差异,因此用户能够轻松理解突出的数据。

步骤 8:变化条形颜色

到目前为止,我们的图表是单色的。这是合理的,因为我们在展示的是同一个值(曼城的胜利)随时间的变化。但条形图同样适合用来比较不同的值。假设我们想要展示多个团队在同一年中的总胜场数,那么为每个团队的条形图使用不同的颜色就显得很有意义。接下来,让我们看看如何做到这一点。

首先,我们需要稍微调整数据结构。之前我们只显示了一个系列。现在,我们希望每个团队都有一个不同的系列。创建多个系列使 Flotr2 能够独立为每个系列上色。以下示例展示了新旧数据系列的对比。我们保留了代码中的wins数组作为对比,但现在要展示的是wins2数组。注意数组的嵌套方式发生了变化。此外,我们将用团队缩写标签替代年份来标注每个条形。

**var** wins = [[[0,13],[1,11],[2,15],[3,15],[4,18],[5,21],[6,28]]];
**var** wins2 = [[[0,28]],[[1,28]],[[2,21]],[[3,20]],[[4,19]]];
**var** teams = [
    [0, "MCI"],
    [1, "MUN"],
    [2, "ARS"],
    [3, "TOT"],
    [4, "NEW"]
];

通过这些更改,我们的数据结构得到了适当的调整,现在可以请求 Flotr2 绘制图表。做这些时,我们将为每个团队使用不同的颜色。其他一切与之前相同。

Flotr.draw(document.getElementById("chart"), wins2, {
    title: "Premier League Wins (2011-2012)",
    colors: ["#89AFD2", "#1D1D1D", "#DF021D", "#0E204B", "#E67840"],
    bars: {
        show: **true**,
        barWidth: 0.5,
        shadowSize: 0,
        fillOpacity: 1,
        lineWidth: 0
    },
    yaxis: {
        min: 0,
        tickDecimals: 0
    },
    xaxis: {
        ticks: teams
    },
    grid: {
        horizontalLines: **false**,
        verticalLines: **false**
    }
});

如你在图 1-5 中所见,通过一些小的调整,我们已经完全改变了条形图的焦点。现在,我们不再显示一个团队在不同时间点的数据,而是在同一时间点比较不同的团队。这就是简单条形图的多功能性。

条形图可以在同一时间点比较不同数量,也可以比较在不同时间点的相同数量。图 1-5. 条形图可以在同一时间点比较不同数量,也可以比较在不同时间点的相同数量。

我们使用了许多不同的代码片段来拼接这些示例。如果你想在一个文件中看到完整的示例,可以查看本书的源代码,地址是jsDataV.is/source/

第 9 步:解决 Flotr2“bug”

如果你在构建内容丰富的大型网页时,可能会遇到一个令人烦恼的 Flotr2“bug”。我把“bug”放在引号里,因为 Flotr2 的这种行为是故意的,但我认为它是不正确的。在构建图表的过程中,Flotr2 会创建一些虚拟的 HTML 元素,以便计算它们的大小。Flotr2 并不打算让这些虚拟元素在页面上显示,因此它通过将它们定位到屏幕外来“隐藏”它们。不幸的是,Flotr2 认为的屏幕外位置并不总是正确的。具体来说,flotr2.js 的第 2,281 行是:

D.setStyles(div, { "position" : "absolute", "top" : "-10000px" });

Flotr2 打算将这些虚拟元素放置在浏览器窗口顶部 10,000 像素的位置。然而,CSS 的绝对定位是相对于包含元素的,这个包含元素不一定是浏览器窗口。因此,如果你的文档高度超过 10,000 像素,你可能会发现 Flotr2 在页面的随机位置散布文本。解决这个 bug 有几种方法,至少在 Flotr2 代码被修订之前可以使用。

一种选择是自己修改代码。Flotr2 是开源的,因此你可以自由下载完整的源代码并进行适当修改。一种简单的修改方法是将虚拟元素定位到页面的右侧或左侧,而不是上方。你可以将代码中的 "top" 改为 "right"。如果你不希望修改库的源代码,另一种选择是自己找到并隐藏这些虚拟元素。你应该在最后一次调用 Flotr.draw() 后进行此操作。最新版本的 jQuery 可以通过以下语句去除这些多余的元素:

$(".flotr-dummy-div").parent().hide();

绘制连续数据的折线图

条形图非常适合可视化少量数据,但对于更多数据,折线图能更有效地呈现信息。折线图特别擅长揭示数据的整体趋势,而不会让用户被个别数据点困扰。

对于我们的示例,我们将关注两个可能相关的指标:大气中的二氧化碳(CO[2])浓度和全球气温。我们想展示这两个指标随时间变化的情况,并且希望了解它们之间的相关性。折线图是查看这些趋势的完美可视化工具。

就像条形图一样,你需要在网页中包含 Flotr2 库,并创建一个 <div> 元素来容纳图表。让我们开始准备数据。

步骤 1:定义数据

我们将从 CO[2] 浓度的测量开始。美国国家海洋和大气管理局(NOAA)发布了自 1959 年至今在夏威夷毛纳罗亚(Mauna Loa)进行的测量数据 (www.esrl.noaa.gov/gmd/ccgg/trends/co2_data_mlo.html)。以下是第一部分数据的摘录。

**var** co2 = [
    [ 1959, 315.97 ],
    [ 1960, 316.91 ],
    [ 1961, 317.64 ],
    [ 1962, 318.45 ],
    *// Data set continues...*

NOAA 还发布了全球平均地表温度的测量数据 (www.ncdc.noaa.gov/cmb-faq/anomalies.php)。这些值衡量的是相对于基准线的差异,基准线目前是整个 20 世纪的平均温度。由于 CO[2] 的测量从 1959 年开始,我们也将使用这一时间点作为温度数据的起点。

**var** temp = [
    [ 1959, 0.0776 ],
    [ 1960, 0.0280 ],
    [ 1961, 0.1028 ],
    [ 1962, 0.1289 ],
    *// Data set continues...*

步骤 2:绘制 CO[2] 数据

使用 Flotr2 绘制一个数据集非常简单。我们只需要调用Flotr对象的draw()方法。该方法所需的唯一参数是指向用于显示图表的 HTML 元素的引用,以及数据本身。数据对象的lines属性表明我们想要绘制一个折线图。

Flotr.draw(
    document.getElementById("chart"),
    [{ data: co2, lines: {show:**true**} }]
);

由于 Flotr2 不需要 jQuery,因此在我们的示例中没有使用任何 jQuery 的便捷函数。如果页面中有 jQuery,可以稍微简化前面的代码。无论如何,图 1-6 展示了结果。

第一个图表显示了一个数据集。图 1-6. 第一个图表显示了一个数据集。

该图表清晰地展示了过去 50 多年 CO[2]浓度的变化趋势。

步骤 3:添加温度数据

通过在代码中简单地添加内容,我们可以在图表中包含温度测量数据。

Flotr.draw(
    document.getElementById("chart"),
    [
        { data: co2, lines: {show:**true**} },
        { data: temp, lines: {show:**true**}, yaxis: 2 }
    ]
);

请注意,我们为温度数据包含了yaxis选项,并将其值设为2。这告诉 Flotr2 为温度数据使用不同的 y 轴比例。

图 1-7 中的图表现在显示了相关年份的两个测量数据,但变得有些拥挤和混乱。数值紧贴图表的边缘,而且当有多个垂直坐标轴时,网格线对用户的理解也变得困难。

一个图表可以显示多个数据集。图 1-7. 一个图表可以显示多个数据集。

步骤 4:提高图表的可读性

通过使用更多 Flotr2 选项,我们可以提高折线图的可读性。首先,我们可以去掉网格线,因为它们对温度测量数据没有帮助。

我们还可以扩展两个垂直坐标轴的范围,为图表提供一些“呼吸空间”。这两个更改是draw()方法的附加选项。

   Flotr.draw(
       document.getElementById("chart"),
       [
           { data: co2, lines: {show:**true**} },
           { data: temp, lines: {show:**true**}, yaxis: 2 }
       ],{
➊         grid: {horizontalLines: **false**, verticalLines: **false**},
➋         yaxis: {min: 300, max: 400},
➌         y2axis: {min: -0.15, max: 0.69}
       }
   );

位于➊的grid选项通过将horizontalLinesverticalLines属性设置为false来关闭网格线。位于➋的yaxis选项指定了第一个垂直坐标轴(CO[2]浓度)的最小值和最大值,而位于➌的y2axis选项则指定了第二个垂直坐标轴(温度差异)的这些值。

图 1-8 中的图形更简洁,且更易于阅读。

去除网格线并扩展坐标轴使图表更易读。图 1-8. 去除网格线并扩展坐标轴使图表更易读。

步骤 5:明确温度测量

温度测量可能仍然会让用户感到困惑,因为这些数据并不是真正的温度;它们实际上是与 20 世纪平均温度的偏差。我们可以通过添加一条 20 世纪平均温度的线并明确标注它来表达这一区别。最简单的方法是创建一个“虚拟”数据集并将其添加到图表中。这个额外的数据集只有零值。

**var** zero = [];
**for** (**var** yr=1959; yr<2012; yr++) { zero.push([yr, 0]); };

当我们将该数据集添加到图表中时,需要指出它对应的是第二个 y 轴。而且因为我们希望这条线作为图表框架的一部分出现,而不是作为另一个数据集,所以我们通过将其宽度设置为一个像素,颜色设为深灰色,并禁用阴影,来使其稍微不那么显眼。

Flotr.draw(
    document.getElementById("chart"),
    [
        { data: zero, lines: {show:**true**, lineWidth: 1}, yaxis: 2,
          shadowSize: 0, color: "#545454" },
        { data: co2, lines: {show:**true**} },
        { data: temp, lines: {show:**true**}, yaxis: 2 }
    ],{
        grid: {horizontalLines: **false**, verticalLines: **false**},
        yaxis: {min: 300, max: 400},
        y2axis: {min: -0.15, max: 0.69}
    }
);

如你所见,我们将零线放在了数据集的最前面。按照这个顺序,Flotr2 会在零线之上绘制实际数据,正如图 1-9 所示,强调其作为图表框架而非数据的作用。

一个虚拟数据集可以突出显示图表坐标轴上的某个位置。图 1-9。一个虚拟数据集可以突出显示图表坐标轴上的某个位置。

第 6 步:标注图表

在本例的最后一步,我们将为图表添加适当的标签。这包括一个整体标题,以及各个数据集的标签。为了清楚地标明哪个坐标轴表示温度,我们将为温度刻度添加“°C”后缀。我们通过在每个数据系列的label选项中指定标签来实现这一点。整体图表标题有一个专门的选项,而“°C”后缀则通过tickFormatter()函数来添加。

   Flotr.draw(
       document.getElementById("chart"),
       [ {
           data: zero,
           label: "20<sup>th</sup>-Century Baseline Temperature",
           lines: {show:**true**, lineWidth: 1},
           shadowSize: 0,
           color: "#545454"
         },
         {
           data: temp,
           label: "Yearly Temperature Difference (°C)",
           lines: {show:**true**}
         },
         {
           data: co2,
           yaxis: 2,
           label: "CO<sub>2</sub> Concentration (ppm)",
           lines: {show:**true**}
         }
       ],
       {
           title: "Global Temperature and CO<sub>2</sub> Concentration (NOAA Data)",
           grid: {horizontalLines: **false**, verticalLines: **false**},
           y2axis: {min: -0.15, max: 0.69,
➊                  tickFormatter: **function**(val) {**return** val+" °C";}}
           yaxis: {min: 300, max: 400},
       }
   );

对于坐标轴上的每个值,格式化函数会被调用,并传入该值,Flotr2 期望它返回一个字符串来作为标签。正如你在➊所看到的,我们只是简单地将" °C"字符串附加到该值后面。

注意,我们还交换了 CO[2]和温度图表的位置。现在我们将温度数据系列放在 CO[2]系列之前。我们这么做是为了让两个温度量(基线和偏差)在图例中并排显示,使它们之间的联系对用户来说更清晰。由于温度现在在图例中位于首位,我们也交换了坐标轴的位置,因此温度坐标轴现在在左边。最后,我们为了同样的原因调整了图表标题。图 1-10 显示了结果。

标注坐标轴并添加图例完成图表的制作。图 1-10。标注坐标轴并添加图例完成图表的制作。

像图 1-10 这样的折线图非常适合可视化这种数据。每个数据集包含超过 50 个点,使得展示每个单独的点变得不切实际。事实上,单独的数据点并不是可视化的重点。我们想展示的是趋势——每个数据集的趋势,以及该数据集与其他数据集之间的关联。用线将这些点连接起来,可以帮助用户快速理解这些趋势和我们可视化的核心内容。

第 7 步:解决 Flotr2 “Bug”

请务必参考创建基本柱状图中的第 9 步,并查看第 9 步:解决 Flotr2 “Bug”,以了解如何绕过 Flotr2 库中的一些“Bug”。

使用饼图强调部分值

饼图在可视化社区中并不受欢迎,这有充分的理由:它们很少是传达数据的最有效方式。我们将在本节中讲解如何创建饼图,但首先让我们花些时间了解它们带来的问题。图 1-11 就是一个例子,它展示了一个简单的饼图。你能从这个图中看出哪个颜色是最大的?哪个是最小的吗?

饼图可能让比较数值变得困难。图 1-11. 饼图可能让比较数值变得困难。

这非常难以判断。原因在于人类并不擅长判断面积的相对大小,尤其是当这些面积不是矩形时。如果我们真的想比较这五个数值,柱状图效果要好得多。图 1-12 显示了相同数值的柱状图。

柱状图通常使比较变得更容易。图 1-12. 柱状图通常使比较变得更容易。

现在,当然很容易对每种颜色进行排名。在柱状图中,我们只需比较一个维度——高度。这给出一个简单的经验法则:如果你在比较不同的数值,首先考虑使用柱状图。它几乎总是能提供最佳的可视化效果。

然而,饼图有一个非常有效的应用场景,那就是我们想要比较一个部分值与整体的关系。例如,假设我们想要可视化世界人口中生活在贫困中的比例。在这种情况下,饼图可能会很好地表现这一点。下面是我们如何使用 Flotr2 构建这样一个图表。

就像在创建基本条形图的第一步中一样,我们需要在网页中包含 Flotr2 库,并为将要构建的图表预留一个<div>元素。

步骤 1:定义数据

这里的数据非常简单。根据世界银行的资料(www.newgeography.com/content/003325-alleviating-world-poverty-a-progress-report),到 2008 年底,世界上有 22.4%的人口每天的收入低于 1.25 美元。这就是我们希望通过图表强调的比例。

**var** data = [[[0,22.4]],[[1,77.6]]];

这里我们有一个包含两个数据系列的数组:一个是贫困人口的百分比(22.4),另一个是其他人群(77.6)。每个系列本身由一个点数组成。在这个例子中,对于饼图而言,每个系列中只有一个点,该点包含一个 x 值和一个 y 值(这两个值一起存储在另一个内部数组中)。对于饼图,x 值是无关紧要的,所以我们简单地使用占位符值01

步骤 2:绘制图表

为了绘制图表,我们调用Flotr对象的draw()方法。该方法接受三个参数:要放置图表的 HTML 文档元素、图表的数据以及任何选项。我们将从饼图所需的最小选项集开始。

   window.onload = **function** () {
       Flotr.draw(document.getElementById("chart"), data, {
           pie: {
               show: **true**
           },
           yaxis: {
➊             showLabels: **false**
           },
           xaxis: {
➋             showLabels: **false**
           },
           grid: {
➌             horizontalLines: **false**,
➍             verticalLines: **false**
           }
       });
   }

如你所见,Flotr2 为最小饼图所需的选项比其他常见图表类型要多一些。对于 x 轴和 y 轴,我们需要禁用标签,这可以通过在➊和➋处将showLabels属性设置为false来实现。我们还必须关闭网格线,因为网格对于饼图没有太大意义。我们通过在➌和➍处将grid选项中的verticalLineshorizontalLines属性设置为false来完成这一操作。

由于 Flotr2 不需要 jQuery,我们在这个例子中没有使用任何 jQuery 的便利函数。如果你的页面中有 jQuery,可以稍微简化一下这段代码。

图 1-13 是一个起点,但很难确切看出图表想要展示什么。

没有有效标签,饼图可能很难解读。图 1-13. 没有效果的标签,饼图可能很难解读。

步骤 3:标记值

下一步是添加一些文本标签和图例,以指示图表展示的内容。为了单独标记每个量,我们需要更改数据结构。我们不再使用系列数组,而是创建一个对象来存储每个系列。每个对象的data属性将包含相应的系列,并且我们将添加一个label属性用于文本标签。

**var** data = [
    {data: [[0,22.4]], label: "Extreme Poverty"},
    {data: [[1,77.6]]}
];

通过这种方式结构化数据,Flotr2 会自动识别与每个系列关联的标签。现在,当我们调用 draw() 方法时,只需添加一个 title 选项。Flotr2 会在图表上方添加标题,并创建一个简单的图例,使用我们的标签标识饼图的各个部分。为了让图表更具吸引力,我们将在标题中提出一个问题。这就是为什么我们只给图表中的一个区域加标签:被标记的区域回答了标题中的问题。

Flotr.draw(document.getElementById("chart"),data, {
    title: "How Much of the World Lives on $1.25/Day?",
    pie: {
        show: **true**
    },
    yaxis: {
        showLabels: **false**
    },
    xaxis: {
        showLabels: **false**
    },
    grid: {
        horizontalLines: **false**,
        verticalLines: **false**
    }
});

图 1-14 中的图表清晰地展示了数据。

标签和标题可以帮助让图表更具吸引力。 图 1-14. 标签和标题可以帮助让图表更具吸引力。

尽管饼图在数据可视化界中名声不佳,但在某些应用场景下,它们还是非常有效的。它们不太适合让用户比较多个值,但正如本示例所示,它们确实提供了一个清晰、易于理解的图像,展示了单一值在整体中的比例。

第 4 步:解决 Flotr2 “Bug”

请务必参考 创建基本条形图 第 9 步,在 第 9 步:解决 Flotr2 “Bug” 中查看如何解决 Flotr2 库中的一些“bug”。

使用散点图绘制 X/Y 数据

条形图通常最适合可视化主要由单一数量构成的数据(比如我们之前创建的条形图中的获胜次数)。但是,如果我们想要探索两个不同数量之间的关系,散点图可能更为有效。例如,假设我们想要可视化一个国家的医疗保健支出(第一个数量)与其预期寿命(第二个数量)之间的关系。让我们通过一个示例,看看如何为这些数据创建一个散点图。

就像在 创建基本条形图 第 1 步中一样,我们需要在网页中包含 Flotr2 库,并为构建的图表预留一个 <div> 元素。

第 1 步:定义数据

在此示例中,我们将使用经济合作与发展组织(OECD)2012 年的报告(* www.oecd-ilibrary.org/social-issues-migration-health/data/oecd-health-statistics_health-data-en *)。该报告包括医疗保健支出占国内生产总值的百分比和出生时的平均预期寿命。(尽管报告在 2012 年底发布,但它包含了 2010 年的数据。)以下是存储在 JavaScript 数组中的该数据的简短摘录:

**var** health_data = [
    { country: "Australia",      spending:  9.1, life: 81.8 },
    { country: "Austria",        spending: 11.0, life: 80.7 },
    { country: "Belgium",        spending: 10.5, life: 80.3 },
    *// Data set continues...*

第 2 步:格式化数据

像往常一样,我们需要稍微重组原始数据,使其符合 Flotr2 的要求格式。接下来展示的 JavaScript 代码就是为此目的。我们从一个空的data数组开始,然后逐步处理源数据。对于每个源health_data中的元素,我们提取图表所需的数据点,并将这些数据点推送到data数组中。

**var** data = [];
**for** (**var** i = 0; i < health_data.length; i++) {
    data.push([
        health_data[i].spending,
        health_data[i].life
    ]);
};

由于 Flotr2 不需要 jQuery,因此在这个例子中我们没有使用任何 jQuery 的便捷函数。但如果你在页面中因其他原因使用了 jQuery,例如,你可以使用.map()函数简化数据重组的代码。(在选择图表内容的步骤 7:根据交互状态确定图表数据中有一个关于 jQuery .map()函数的详细示例。)

步骤 3:绘制数据

现在我们需要做的就是调用draw()方法,来创建我们的图表。初次尝试时,我们将使用默认选项。

Flotr.draw(
    document.getElementById("chart"),
    [{ data: data, points: {show:**true**} }]
);

如你所见,Flotr2 至少需要两个参数。第一个是我们希望图表显示的 HTML 文档元素,第二个是图表的数据。数据的形式是一个数组。一般来说,Flotr2 可以在同一图表上绘制多个系列,因此这个数组可能包含多个对象。然而,在我们的例子中,我们只绘制一个系列,因此数组只有一个对象。该对象标识数据本身,并告诉 Flotr2 不要显示点,而是显示线条。

图 1-15 展示了我们的结果。注意图中的点是如何紧贴着图表边缘的。

默认散点图选项没有提供任何边距。图 1-15. 默认散点图选项没有提供任何边距。

步骤 4:调整图表的坐标轴

第一次尝试还不错,但 Flotr2 会自动计算每个轴的范围,且其默认算法通常会导致图表过于拥挤。Flotr2 确实有一个autoscale选项;如果启用它,库会尝试自动为关联的轴找到合理的范围。不幸的是,根据我的经验,Flotr2 建议的范围很少能显著改善默认选项,因此在大多数情况下,明确设置范围会更好。以下是我们如何为图表设置这些范围的方法:

Flotr.draw(
    document.getElementById("chart"),
    [{
        data: data,
        points: {show:**true**}
    }],
    {
        xaxis: {min: 5, max: 20},
        yaxis: {min: 70, max: 85}
    }
);

我们在 draw() 方法中添加了一个包含我们选项的第三个参数,这些选项是 x 轴和 y 轴的属性。在每个案例中,我们都显式地设置了最小值和最大值。通过指定给数据一点呼吸空间的范围,我们使得图表 图 1-16 更易于阅读。

指定我们自己的坐标轴使图表更易于阅读。图 1-16. 指定我们自己的坐标轴使图表更易于阅读。

步骤 5: 标注数据

到目前为止,我们的图表看起来相当不错,但它并没有告诉用户他们正在查看什么。我们需要添加一些标签来标识数据。再添加几个选项可以让图表更清晰:

Flotr.draw(
    document.getElementById("chart"),
    [{
        data: data, points: {show:**true**}
    }],
    {
        title: "Life Expectancy vs. Health-Care Spending",
        subtitle: "(by country, 2010 OECD data)",
        xaxis: {min: 5, max: 20, ➊tickDecimals: 0,
                title: "Spending as Percentage of GDP"},
        yaxis: {min: 70, max: 85, ➋tickDecimals: 0, title: "Years"}
    }
);

titlesubtitle 选项为图表提供了整体的标题和副标题,而 xaxisyaxis 选项中的 title 属性则命名了这些坐标轴的标签。除了添加标签,我们还通过在 ➊ 和 ➋ 处更改 tickDecimals 属性,指示 Flotr2 去除 x 轴和 y 轴值中不必要的小数点。图表 图 1-17 看起来好多了。

标签和标题明确图表的内容。图 1-17. 标签和标题明确图表的内容。

步骤 6: 明确 X 轴

尽管我们的图表自第一次尝试以来已经有所改进,但数据呈现仍然存在一个令人困扰的问题。x 轴表示百分比,但该轴的标签显示的是整数。这种不一致可能会让用户产生初步困惑,因此我们需要解决它。Flotr2 允许我们按需格式化坐标轴标签。在这个例子中,我们只希望在数值后添加一个百分号。这个操作非常简单:

   Flotr.draw(
       document.getElementById("chart"),
       [{
           data: data, points: {show:**true**}
       }],
       {
           title: "Life Expectancy vs. Health-Care Spending",
           subtitle: "(by country, 2010 OECD data)",
           xaxis: {min: 5, max: 20, tickDecimals: 0,
                  title: "Spending as Percentage of GDP",
➊                tickFormatter: **function**(val) {**return** val+"%"}},
           yaxis: {min: 70, max: 85, tickDecimals: 0, title: "Years"}
       }
   );

关键是前面代码中 xaxis 选项里的 tickFormatter 属性,位于 ➊。该属性指定了一个函数。当存在 tickFormatter 时,Flotr2 不会自动绘制标签。而是在每个本该绘制标签的点上,它会调用我们的函数。传递给函数的参数是标签的数值。Flotr2 期望函数返回一个字符串,作为要使用的标签。在我们的例子中,我们只是简单地在数值后面添加一个百分号。

在 图 1-18 中,随着横坐标轴上添加了百分比值标签,我们得到了一张清晰呈现数据的图表。

格式化坐标轴标签可以明确内容。图 1-18. 格式化坐标轴标签可以明确内容。

散点图在揭示两个不同变量之间的关系方面表现出色。在这个例子中,我们可以看到寿命预期与医疗支出的关系。总的来说,更多的支出会带来更长的寿命。

第 7 步:回答用户的问题

现在我们的图表成功地展示了数据,我们可以开始从用户的角度更仔细地审视可视化内容。我们特别想预见用户可能会提出的问题,并尽量直接在图表上解答。目前图表上至少有三个问题:

  1. 显示的是哪些国家?

  2. 是否存在地区差异?

  3. 那个位于最右边的数据点是什么?

解答这些问题的一种方法是为每个数据点添加鼠标悬停(或工具提示)。但我们在这个例子中不会使用这种方法,原因有几个。首先(最明显的是),互动式可视化是第二章的内容;本章只考虑静态图表和图形。其次,鼠标悬停和工具提示对使用触摸设备(如智能手机或平板电脑)的用户来说效果不佳。如果我们要求用户必须使用鼠标才能完全理解我们的可视化,那么我们可能会忽视一个重要的(且快速增长的)用户群体。

我们解决这个问题的方法是将数据拆分成多个系列,以便我们可以为每个系列单独上色和标注。下面是将数据拆分成区域的第一步:

**var** pacific_data = [
    {  country: "Australia",      spending:  9.1, life: 81.8 },
    {  country: "New Zealand",    spending: 10.1, life: 81.0 },
];
**var** europe_data = [
    {  country: "Austria",        spending: 11.0, life: 80.7 },
    {  country: "Belgium",        spending: 10.5, life: 80.3 },
    {  country: "Czech Republic", spending:  7.5, life: 77.7 },

*// Data set continues...*

**var** us_data = [
    {  country: "United States",  spending: 17.6, life: 78.7 }
];

在这里,我们为美国单独设立了一个系列,而不是与美洲系列合并。因为美国是图表右侧的离群值数据点。用户可能想要知道该数据点对应的具体国家,而不仅仅是它所属的区域。对于其他国家,仅仅显示区域就足够了。如同之前一样,我们需要将这些数组重新构造为 Flotr2 的格式。代码与第 4 步相同;我们只是对每个数据集重复这个过程。

**var** pacific=[], europe=[], americas=[], mideast=[], asia=[], us=[];
**for** (i = 0; i < pacific_data.length; i++) {
    pacific.push([ pacific_data[i].spending, pacific_data[i].life ]);
}
**for** (i = 0; i < europe_data.length; i++) {
    europe.push([ europe_data[i].spending, europe_data[i].life ]);
}
*// Code continues...*

一旦我们将国家分离开来,就可以将它们的数据作为不同的系列传递给 Flotr2。这里我们可以看到为什么 Flotr2 期望将数组作为其数据参数。每个系列在数组中都是一个独立的对象。

Flotr.draw(
    document.getElementById("chart"),
    [
        { data: pacific,  points: {show:**true**} },
        { data: europe,   points: {show:**true**} },
        { data: americas, points: {show:**true**} },
        { data: mideast,  points: {show:**true**} },
        { data: asia,     points: {show:**true**} },
        { data: us,       points: {show:**true**} }
    ],{
        title: "Life Expectancy vs. Health-Care Spending",
        subtitle: "(by country, 2010 OECD data)",
        xaxis: {min: 5, max: 20, tickDecimals: 0,
                title: "Spending as Percentage of GDP",
                tickFormatter: **function**(val) {**return** val+"%"}},
        yaxis: {min: 70, max: 85, tickDecimals: 0, title: "Years"}
    }
);

基于地区对不同数据系列中的国家进行分类后,Flotr2 现在为各个区域着上了不同的颜色,如图 1-19 所示。

将数据拆分成多个数据集让我们可以为每个数据集分配不同的颜色。图 1-19. 将数据拆分成多个数据集让我们可以为每个数据集分配不同的颜色。

最后的增强功能是向图表中添加一个图例,用来标识各个区域。

   Flotr.draw(
       document.getElementById("chart"),
       [
           { data: pacific,  label: "Pacific", points: {show:**true**} },
           { data: europe,   label: "Europe", points: {show:**true**} },
           { data: americas, label: "Americas", points: {show:**true**} },
           { data: mideast,  label: "Middle East", points: {show:**true**} },
           { data: asia,     label: "Asia", points: {show:**true**} },
           { data: us,       label: "United States", points: {show:**true**} }
       ],{
           title: "Life Expectancy vs. Health-Care Spending (2010 OECD data)",
➊         xaxis: {min: 5, max: 25, tickDecimals: 0,
                  title: "Spending as Percentage of GDP",
                  tickFormatter: **function**(val) {**return** val+"%"}},
           yaxis: {min: 70, max: 85, tickDecimals: 0, title: "Years"},
➋         legend: {position: "ne"}
       }
   );

为了腾出空间给图例,我们在➊处增加了 x 轴的范围,并将图例放置在➋的东北象限。

这个附加步骤给我们带来了最终图表,如图 1-20 所示。

添加图例完成图表。图 1-20. 添加图例完成图表。

第 8 步:解决 Flotr2“Bug”

请务必参考创建基本条形图中的第 9 步,在第 9 步:解决 Flotr2“Bug”中查看如何解决 Flotr2 库中的一些“Bug”。

使用气泡图为 X/Y 数据添加量级

传统的散点图,如前面的示例所示,展示了两个值之间的关系:x 轴和 y 轴。然而,有时候,两个值不足以展示我们想要可视化的数据。如果我们需要可视化三个变量,可以使用散点图框架展示两个变量,然后根据第三个变量的不同调整点的大小。最终得到的图表就是气泡图。

然而,有效使用气泡图需要一些谨慎。如我们之前在饼图中看到的那样,人类在准确判断非矩形形状的相对面积方面并不擅长,因此气泡图不适合精确比较气泡的大小。但如果你的第三个变量只传达某种量的总体印象,而不是准确的测量,气泡图可能是合适的。

在这个示例中,我们将使用气泡图来可视化 2005 年卡特里娜飓风的路径。我们的 x 和 y 值将代表位置(纬度和经度),并且我们会确保用户能够准确解读这些值。对于第三个值——气泡的面积——我们将使用风暴的持续风速。由于风速本身只是一个大致的值(因为风速会时强时弱),因此提供一个大致的印象就足够了。

就像在创建基本条形图的第 1 步中一样,我们需要在网页中包含 Flotr2 库,并为将要构建的图表预留一个<div>元素。

第 1 步:定义数据

我们将以美国国家海洋和大气管理局(NOAA)收集的卡特里娜飓风观测数据为例。数据包括观测的纬度和经度,以及以每小时英里为单位的持续风速。

**var** katrina = [
    { north: 23.2, west: 75.5, wind: 35 },
    { north: 24.0, west: 76.4, wind: 35 },
    { north: 25.2, west: 77.0, wind: 45 },
    *// Data set continues...*

对于气泡图,Flotr2 要求每个数据点是一个数组,而不是一个对象,因此我们需要构建一个简单的函数,将源数据转换为这种格式。为了让函数更具通用性,我们可以使用一个可选参数来指定一个过滤函数。并且,在提取数据点时,我们可以反转经度的符号,这样从西到东的方向就会从左到右显示。

   **function** get_points(source_array, filter_function) {
➊     **var** result = [];
       **for** (**var** i=0; i<source_array.length; i++) {
           **if** ( (**typeof** filter_function === "undefined")
             || (**typeof** filter_function !== "function")
             || filter_function(source_array[i]) ) {
               result.push([
                   source_array[i].west * -1,
                   source_array[i].north,
                   source_array[i].wind
               ]);
           }
       }
       **return** result;
   }

我们的函数代码首先在➊处将返回值(result)设置为空数组。然后它逐一遍历输入的source_array。如果filter_function参数存在,并且它是一个有效的函数,我们的代码会调用这个函数,并将源数组元素作为参数。如果函数返回true,或者没有传入任何函数参数,那么我们的代码就会从源元素中提取数据点并将其推送到结果数组中。

如你所见,filter_function参数是可选的。如果调用者省略了它(或者它不是一个有效的函数),那么源中的每个点都会进入结果。我们暂时不会立即使用过滤函数,但它将在本示例的后续步骤中派上用场。

第 2 步:为图表创建背景

因为我们图表的 x 值和 y 值将表示位置,所以地图是完美的图表背景。为了避免任何版权问题,我们将使用 Stamen Design 提供的地图图片 (stamen.com/), 这些地图使用的是 OpenStreetMap 提供的数据 (openstreetmap.org/)。这两个资源都可以在 Creative Commons 许可下使用,分别是 CC BY 3.0 (creativecommons.org/licenses/by/3.0) 和 CC BY SA (creativecommons.org/licenses/by-sa/3.0)。

当你在处理地图时,投影可能是一个棘手的问题,但映射区域越小,投影的影响就越小,而且在映射区域的中心投影影响较小。在这个例子中,考虑到其相对较小的区域以及聚焦于中心的行动,我们假设地图图像使用的是墨卡托投影。这个假设让我们在将纬度和经度转换为 x 值和 y 值时避免了任何复杂的数学变换。

图 1-21 展示了我们将叠加飓风路径的地图图像。

地图图像可以作为图表的背景。图 1-21. 地图图像可以作为图表的背景。

第 3 步:绘制数据

我们需要经过几次迭代才能使图表达到我们想要的效果,但让我们从最少的选项开始。我们需要指定的一个参数是气泡的半径。对于像这样的静态图表,最简单的方法是通过尝试几个值来找到最佳大小。0.3的值似乎对我们的图表有效。除了选项,draw()方法还需要一个 HTML 元素来包含图表,以及数据本身。

Flotr.draw(
    document.getElementById("chart"),
    [{
      data: get_points(katrina),
      bubbles: {show:**true**, baseRadius: 0.3}
    }]
);

如你所见,我们正在使用我们的转换函数从源中提取数据。该函数的返回值直接作为draw()的第二个参数。

目前,我们还没有处理背景图像。我们会在稍微调整数据后将其添加到图表中。图 1-22 中的结果(Figure 1-22)仍然需要改进,但它是一个可行的起点。

基本的气泡图会根据数据点的大小变化。 图 1-22。基本的气泡图会根据数据点的大小变化。

步骤 4:添加背景

现在我们已经了解了 Flotr2 如何绘制数据,接下来可以添加背景图像。我们还需要同时做一些其他修改。首先,既然要添加背景,就可以去掉网格线。其次,禁用轴标签;纬度和经度的数值对于普通用户意义不大,且在地图上并不需要。最后,也是最重要的,我们需要调整图表的比例,以适应地图图像。

   Flotr.draw(
       document.getElementById("chart"),
       [{
         data: get_points(katrina),
         bubbles: {show:**true**, baseRadius: 0.3}
       }],
       {
➊         grid: {
               backgroundImage: "img/gulf.png",
               horizontalLines: **false**,
               verticalLines: **false**
           },
➋         yaxis: {showLabels: **false**, min: 23.607, max: 33.657},
➌         xaxis: {showLabels: **false**, min: -94.298, max: -77.586}
       }
   );

我们从 ➊ 开始添加了 grid 选项,告诉 Flotr2 省略水平和垂直的网格线,并指定背景图像。我们的图像显示了从 23.607°N 到 33.657°N 的纬度值,以及从 77.586°W 到 94.298°W 的经度值。在 ➋ 和 ➌,我们为 xaxisyaxis 选项提供了这些值作为范围,并禁用了两个轴的标签。注意,由于我们处理的是零度经线以西的经度,因此我们使用负值。

到目前为止,图 1-23 中的图表(Figure 1-23)看起来相当不错。我们可以清楚地看到飓风的路径,并感受到风暴是如何增强和减弱的。

以地图为背景图像,图表有了更有意义的上下文。 图 1-23。以地图为背景图像,图表有了更有意义的上下文。

步骤 5:为气泡上色

这个例子给了我们一个机会,向用户提供更多的信息,而不会过度分散他们的注意力:我们可以修改气泡颜色。让我们利用这个自由,表示每个测量点的 Saffir-Simpson 风暴强度分类。

在这里,我们可以利用我们在数据格式化函数中包含的筛选选项。Saffir-Simpson 分类是基于风速的,因此我们将基于 wind 属性进行筛选。例如,下面是如何提取仅代表 1 级飓风(风速在每小时 74 到 95 英里之间)的数据。我们传递给 get_points 的函数仅对适当的风速返回 true

cat1 = get_points(katrina, **function**(obs) {
    **return** (obs.wind >= 74) && (obs.wind < 95);
});

要让 Flotr2 为不同的强度分配不同的颜色,我们使用以下代码将数据分成多个系列,每个系列都有自己的颜色。除了五个飓风等级,我们还提取了热带风暴和热带低气压的强度数据点。

Flotr.draw(
    document.getElementById("chart"),
    [
      {
          data: get_points(katrina, **function**(obs) {
                    **return** (obs.wind < 39);
                }),
          color: "#74add1",
          bubbles: {show:**true**, baseRadius: 0.3, lineWidth: 1}
      },{
      *// Options continue...*
      },{
          data: get_points(katrina, **function**(obs) {
                    **return** (obs.wind >= 157);
                }),
          color: "#d73027",
          label: "Category 5",
          bubbles: {show:**true**, baseRadius: 0.3, lineWidth: 1}
      }
    ],{
        grid: {
            backgroundImage: "img/gulf.png",
            horizontalLines: **false**,
            verticalLines: **false**
        },
        yaxis: {showLabels: **false**, min: 23.607, max: 33.657},
        xaxis: {showLabels: **false**, min: -94.298, max: -77.586},
        legend: {position: "sw"}
    }
);

我们还为飓风类别添加了标签,并将图例放置在图表的左下角,正如你在图 1-24 中看到的那样。

不同的颜色可以表示风力强度。图 1-24。不同的颜色可以表示风力强度。

第 6 步:调整图例样式

默认情况下,Flotr2 似乎偏好将所有元素显示得尽可能大。图 1-24 中的图例就是一个很好的例子:它看起来拥挤且不美观。幸运的是,解决方法非常简单:我们只需添加一些 CSS 样式来为图例添加内边距。我们还可以显式设置图例的背景色,而不是依赖 Flotr2 对透明度的处理。

.flotr-legend **{**
    **padding:** 5px**;**
    **background-color:** #ececec**;**
**}**

为了防止 Flotr2 为图例创建自己的背景,将透明度设置为 0

Flotr.draw(
    document.getElementById("chart")
        *// Additional options...*
        legend: {position: "sw", backgroundOpacity: 0,},
        *// Additional options...*

经过最后的调整,我们得到了图 1-25 的最终产品。我们不想使用 Flotr2 选项来指定标题,因为 Flotr2 会以无法预测的量缩小图表区域(因为我们无法预测用户浏览器中的字体大小)。这会扭曲我们的纬度转换。当然,使用 HTML 来提供标题是足够简单的。

气泡图展示了第三个维度(风速)以及位置。图 1-25。气泡图展示了第三个维度(风速)以及位置。

气泡图为二维散点图增加了另一个维度。事实上,正如我们例子中所示,它甚至可以增加两个维度。该例子使用气泡大小来表示风速,使用颜色来指示飓风的分类。然而,这两个附加的值需要小心处理。人类在比较二维面积方面并不擅长,也无法轻松比较相对的色调或颜色。我们绝不应该使用额外的气泡图维度来传达关键信息或精确数据。相反,它们在像这样的一些例子中最有效——风速的确切数值或飓风的具体分类不需要像位置那样精准。很少有人能够区分 100 英里每小时和 110 英里每小时的风速,但他们一定能分清新奥尔良和达拉斯之间的差异。

第 7 步:解决 Flotr2 的“bug”

确保参考创建基本条形图中的第 9 步以及第 9 步:解决 Flotr2 的“bug”,了解如何解决 Flotr2 库中的一些“bug”。

使用雷达图展示多维数据

如果你的数据有很多维度,雷达图可能是最有效的可视化方式。不过,雷达图不像其他图表那样常见,它的不熟悉性使得用户解读起来稍显困难。如果你设计雷达图,要小心不要增加这种负担。

雷达图在数据具有多个特征时最为有效:

  • 你展示的数据点不应太多。雷达图能够容纳的最大数据点大约是半打。

  • 数据点具有多个维度。如果数据只有两个甚至三个维度,使用更传统的图表类型可能会更好。雷达图适用于四个或更多维度的数据。

  • 每个数据维度都是一个至少可以排序的尺度(比如从好到坏),如果不能直接分配数值的话。雷达图不适合处理仅仅是任意类别的数据维度(比如政党或国籍)。

雷达图的经典用途是分析运动队球员的表现。例如,考虑一下 2012 年迈阿密热火队的首发阵容,这支队伍来自美国职业篮球联赛(NBA)。这里只有五个数据点(五名球员)。有多个维度——得分、助攻、篮板、盖帽、抢断等等——每个维度都有一个自然的数值。

表 1-1 展示了球员们 2011–2012 赛季的每场比赛平均数据,以及球队的总计(包括替补球员的贡献)。

表 1-1. 迈阿密热火队 2011–2012 赛季

球员 得分 篮板 助攻 抢断 盖帽
克里斯·波什 17.2 7.9 1.6 0.8 0.8
谢恩·巴蒂尔 5.4 2.6 1.2 1.0 0.5
勒布朗·詹姆斯 28.0 8.4 6.1 1.9 0.8
德维恩·韦德 22.3 5.0 4.5 1.7 1.3
马里奥·查尔默斯 10.2 2.9 3.6 1.4 0.2
团队总计 98.2 41.3 19.3 8.5 5.3

就像在创建基本条形图的步骤 1 中一样,我们需要在网页中引入 Flotr2 库,并为构建的图表预留一个<div>元素。

步骤 1:定义数据

我们将从一个典型的 JavaScript 表达式开始,表示球队的统计数据。在这个例子中,我们将从一个对象数组开始,每个对象对应一个首发球员,并为整个团队单独创建一个对象。

**var** players = [
    { player: "Chris Bosh",     points: 17.2, rebounds: 7.9, assists: 1.6,
      steals: 0.8, blocks: 0.8 },
    { player: "Shane Battier",  points:  5.4, rebounds: 2.6, assists: 1.2,
      steals: 1.0, blocks: 0.5 },
    { player: "LeBron James",   points: 28.0, rebounds: 8.4, assists: 6.1,
      steals: 1.9, blocks: 0.8 },
    { player: "Dwyane Wade",    points: 22.3, rebounds: 5.0, assists: 4.5,
      steals: 1.7, blocks: 1.3 },
    { player: "Mario Chalmers", points: 10.2, rebounds: 2.9, assists: 3.6,
      steals: 1.4, blocks: 0.2 }
];
**var** team = {
    points:   98.2,
    rebounds: 41.3,
    assists:  19.3,
    steals:    8.5,
    blocks:    5.3
};

对于有效的雷达图,我们需要将所有值标准化到一个共同的尺度。在这个例子中,我们将原始统计数据转换为团队百分比。例如,不是将勒布朗·詹姆斯的得分显示为 28.0,而是显示为 29 百分比(28.0/98.2)。

我们可以使用几个函数将原始统计数据转换为可以绘制的对象。第一个函数返回单个球员的 statistics 对象,它通过在 players 数组中搜索该球员的名字来完成。第二个函数遍历 team 对象中的每个统计数据,获取该球员的相应统计数据,并将值归一化。返回的对象将具有一个等于球员姓名的 label 属性,并且包含该球员的归一化统计数据数组。

**var** get_player = **function**(name) {
   **for** (**var** i=0; i<players.length; i++) {
       **if** (players[i].player === name) **return** players[i];
   }
}
**var** player_data = **function**(name) {
    **var** obj = {}, i = 0;
    obj.label = name;
    obj.data = [];
    **for** (**var** key **in** team) {
        obj.data.push([i, 100*get_player(name)[key]/team[key]]);
        i++;
    };
    **return** obj;
};

例如,函数调用 player_data(``"``LeBron James``"``) 返回以下对象:

{
    label: "LeBron James",
    data: [
               [0,28.513238289205702],
               [1,20.33898305084746],
               [2,31.60621761658031],
               [3,22.352941176470587],
               [4,15.09433962264151]
          ]
}

对于具体的统计数据,我们使用一个从 0 到 4 的计数器。稍后我们会看到如何将这些数字映射到有意义的值。

由于 Flotr2 不需要 jQuery,我们在前面的代码中并没有利用任何 jQuery 的便利函数。我们也没有充分利用 JavaScript 标准(包括 .each() 这样的函数),因为版本 9 之前的 Internet Explorer 不支持这些方法。如果你已经在页面中使用了 jQuery,或者不需要支持旧版的 IE,可以大大简化这段代码。

我们将使用的最后一段代码是一个简单的标签数组,用于图表中的统计数据。顺序必须与 player_data() 返回的顺序相匹配。

**var** labels = [
    [0, "Points"],
    [1, "Rebounds"],
    [2, "Assists"],
    [3, "Steals"],
    [4, "Blocks"]
];

步骤 2:创建图表

只需一次调用 Flotr2 的 draw() 方法,就能创建我们的图表。我们需要指定放置图表的 HTML 元素以及图表数据。对于数据,我们将使用前面展示的 get_player() 函数。

   Flotr.draw(document.getElementById("chart"),
       [
           player_data("Chris Bosh"),
           player_data("Shane Battier"),
           player_data("LeBron James"),
           player_data("Dwyane Wade"),
           player_data("Mario Chalmers")
       ],{
➊         title:
               "2011/12 Miami Heat Starting Lineup — Contribution to Team Total",
➋         radar: { show: **true** },
➌         grid:  { circular: **true**, },
           xaxis: { ticks: labels, },
           yaxis: { showLabels: **false**, min:0, max: 33, }
       }
   );

这段代码还包括了一些选项。在 ➊ 处的 title 选项为图表提供了一个总标题,而在 ➋ 处的 radar 选项告诉 Flotr2 我们想要的图表类型。对于雷达图,我们还必须明确指定一个圆形(而非矩形)网格,因此我们在 ➌ 处使用 grid 选项来做到这一点。最后两个选项详细说明了 x 轴和 y 轴。对于 x 轴,我们使用 labels 数组为每个统计数据命名,而对于 y 轴,我们完全省略了标签,明确指定了最小值和最大值。

唯一的技巧是使 HTML 容器足够宽,以容纳图表本身和图例,因为 Flotr2 在计算合适的大小时表现不太好。对于像这样的静态图表,试错法是最简单的方法,它给我们展示了图表 图 1-26。

雷达图让用户一次性比较多个数据变量。图 1-26. 雷达图让用户一次性比较多个数据变量。

虽然这对 NBA 球迷来说并不意外,但图表清晰地展示了 LeBron James 对球队的价值。他在五大主要统计类别中有四个领衔。

雷达图适用于少数几个特殊应用场景,但当变量数量适中且每个变量都能轻松量化时,雷达图非常有效。在图 1-26 中,每个玩家在图表中的区域大致对应其在所有变量中的总贡献。红色区域的相对大小清晰地显示了詹姆斯的总贡献。

第三步:解决 Flotr2 “bug”

请务必参考创建基本柱状图中的第 9 步,查看如何解决 Flotr2 库中的一些“bug”。

总结

本章中的示例提供了快速浏览多种标准数据图表的方式,这些图表是可视化数据的最简单、最直接的工具。每种图表在某些特定类型的可视化中尤为有效。

  • 柱状图。图表中的主力工具。适用于展示某一数量随少数规则时间间隔变化,或用于比较几个不同的数量。

  • 折线图。当数据值较多或用于展示变化不规则的数量时,比柱状图更有效。

  • 饼图。虽然常常被过度使用,但在突出显示单一值在整体中所占比例时,饼图仍然非常有效。

  • 散点图。有效地展示两个数值之间的可能关系。

  • 气泡图。在散点图的基础上增加了第三个数值,但使用时要小心,因为很难准确评估圆形区域的相对面积。

  • 雷达图。设计用来在一张图表上展示多个方面的内容。虽然许多用户不太熟悉,但在某些特殊情况下,雷达图非常有效。

第二章:使图表具有交互性

在第一章中,我们学习了如何创建各种简单的静态图表。在许多情况下,这些图表是理想的可视化方式,但它们没有利用互联网的一个重要特性——交互性。有时候,你希望做的不仅仅是展示数据,而是希望给用户一个机会,让他们能够探索数据、聚焦他们感兴趣的元素,或是考虑不同的情境。在这种情况下,我们可以通过为可视化内容添加交互性,来充分利用互联网这一媒介。

因为它们是为互联网设计的,几乎所有我们在本书中讨论的库和工具包都支持交互性。Flotr2 库就是在第一章中使用的一个例子,它肯定符合这一特点。但让我们抓住机会探索一个替代方案。在本章中,我们将使用Flot 库www.flotcharts.org/),它基于 jQuery,并且在交互式和实时图表方面提供了异常强大的支持。

在本章中,我们仍然会使用一个数据源:全球各国的国内生产总值(GDP)。这些数据可以从世界银行data.worldbank.org/)公开获取。虽然这看起来并不是最激动人心的数据,但有效的可视化可以让即便是最平凡的数据也焕发活力。你将在本章学到以下内容:

  • 如何让用户选择图表的内容

  • 如何让用户放大图表以查看更多细节

  • 如何使图表响应用户的鼠标移动

  • 如何通过 AJAX 服务动态获取图表数据

选择图表内容

如果你在网上向大量观众展示数据,你可能会发现不同的用户特别关注数据的不同方面。例如,针对全球 GDP 数据,我们可能会预期个别用户最感兴趣的是自己所在地区的数据。如果我们能够预见到用户会有类似的需求,我们就可以根据这些需求来构建我们的可视化。

在这个例子中,我们面向的是全球观众,并且希望展示所有地区的数据。然而,为了适应个别用户的需求,我们可以使各个地区的数据可选择;也就是说,用户可以选择显示或隐藏各个地区的数据。如果某些用户对某些地区的数据不感兴趣,他们可以简单地选择不显示这些数据。

交互式可视化通常比简单的静态图表需要更多的思考。数据的初始展示不仅必须有效,用户控制展示的方式以及展示响应的方式也必须有效。通常,明确地考虑这些要求会有助于提高效果。

  1. 确保最初的静态展示有效地显示数据。

  2. 向页面添加任何用户控件,并确保它们对于可视化是合理的。

  3. 添加使控件工作的代码。

我们将在以下示例中逐一解决这些阶段。

第 1 步:包含所需的 JavaScript 库

由于我们使用 Flot 库来创建图表,因此需要在网页中包含该库。由于 Flot 需要 jQuery,我们也会将 jQuery 包含在页面中。幸运的是,jQuery 和 Flot 都是流行的库,并且它们可以在公共的 内容分发网络 (CDN) 上找到。这使得你可以选择从 CDN 加载它们,而不是在自己的网站上托管它们。依赖 CDN 有几个优点:

  • 更好的性能。如果用户之前访问过其他从相同 CDN 获取库的网站,那么这些库可能已经存在于浏览器的本地缓存中。在这种情况下,浏览器只需从缓存中获取这些库,避免了额外网络请求的延迟。(参见下一个列表中的第二个缺点,对于性能有不同的看法。)

  • 成本更低。无论如何,你的网站成本通常是基于你使用的带宽量。如果用户能够从 CDN 获取库,那么处理他们请求所需的带宽就不算作你网站的带宽使用。

    当然,CDN 也有一些缺点。

  • 失去控制。如果 CDN 崩溃,那么你页面所需的库将无法使用。这将使你网站的功能受制于 CDN。虽然有一些方法可以缓解这种故障,你可以尝试从 CDN 获取库,如果请求失败,则回退到自己托管的副本。然而,实施这种回退机制比较复杂,可能会在你的代码中引入错误。

  • 缺乏灵活性。使用 CDN 托管的库时,你通常会受到有限选项的限制。例如,在这种情况下,我们需要同时使用 jQuery 和 Flot 库。CDN 只提供这些库作为独立的文件,因此为了获取这两个库,我们需要进行两次网络请求。另一方面,如果我们自己托管这些库,我们可以将它们合并成一个文件,从而减少请求的次数。对于高延迟的网络(例如移动网络),请求次数可能是决定网页性能的最大因素。

对于所有情况,并没有明确的答案,因此你需要根据自己的需求权衡选择。对于这个示例(以及本章中的其他示例),我们将使用 CloudFlare CDN。

除了 jQuery 库,Flot 还依赖于 HTML canvas 特性。为了支持 IE8 及更早版本,我们将在页面中包含 excanvas.min.js 库,并确保只有 IE8 及更早版本会加载它,就像我们在第一章中的条形图所做的那样。此外,由于 excanvas 在公共 CDN 上不可用,我们必须将其托管在自己的服务器上。下面是开始时的框架:

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>**
    **<meta** charset="utf-8"**>**
    **<title></title>**
  **</head>**
  **<body>**
    *<!-- Content goes here -->*
    *<!--[if lt IE 9]><script src="js/excanvas.min.js"></script><![endif]-->*
    **<script** src="//cdnjs.cloudflare.com/ajax/libs/jquery/1.8.3/jquery.min.js"**>**
    **</script>**
    **<script** src="//cdnjs.cloudflare.com/ajax/libs/flot/0.7/jquery.flot.min.js"**>**
    **</script>**
  **</body>**
**</html>**

如你所见,我们在文档的末尾包含了 JavaScript 库。这种做法让浏览器在等待服务器提供 JavaScript 库时,能够先加载整个 HTML 标记并开始布局页面。

步骤 2:为图表预留一个
元素

在我们的文档中,我们需要创建一个 <div> 元素来容纳我们将构建的图表。这个元素必须具有明确的高度和宽度,否则 Flot 将无法构建图表。我们可以在 CSS 样式表中指定元素的大小,或者直接在元素本身上设置。以下是使用后者方法时文档的样子。

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
     **</head>**
     **<body>**
➊     **<div** id="chart" style="width:600px;height:400px;"**></div>**
       *<!--[if lt IE 9]><script src="js/excanvas.min.js"></script><![endif]-->*
       **<script** src="//cdnjs.cloudflare.com/ajax/libs/jquery/1.8.3/jquery.min.js"**>**
       **</script>**
       **<script** src="//cdnjs.cloudflare.com/ajax/libs/flot/0.7/jquery.flot.min.js"**>**
       **</script>**
     **</body>**
   **</html>**

请注意 ➊,我们为 <div> 元素指定了一个明确的 id,以便后续引用。

步骤 3:准备数据

在后续的例子中,我们将看到如何直接从世界银行的网络服务获取数据,但在这个例子中,为了简化,我们假设数据已经下载并且格式化为 JavaScript 使用。(为了简洁,这里仅展示了部分内容。书本的源代码包括完整的数据集。)

**var** eas = [[1960,0.1558],[1961,0.1547],[1962,0.1574], *// Data continues...*
**var** ecs = [[1960,0.4421],[1961,0.4706],[1962,0.5145], *// Data continues...*
**var** lcn = [[1960,0.0811],[1961,0.0860],[1962,0.0990], *// Data continues...*
**var** mea = [[1968,0.0383],[1969,0.0426],[1970,0.0471], *// Data continues...*
**var** sas = [[1960,0.0478],[1961,0.0383],[1962,0.0389], *// Data continues...*
**var** ssf = [[1960,0.0297],[1961,0.0308],[1962,0.0334], *// Data continues...*

这些数据包括了 1960 到 2011 年期间,世界主要地区的历史 GDP(按当前美元计算)。变量名称为世界银行的区域代码。

注意

在本文写作时,世界银行关于北美的数据暂时无法获取。

步骤 4:绘制图表

在我们添加任何交互功能之前,先来看一下图表本身。Flot 库提供了一个简单的函数调用来创建静态图表。我们调用 jQuery 扩展 plot 并传递两个参数。第一个参数标识了应该包含图表的 HTML 元素,第二个参数提供了作为数据集数组的数据。在这个例子中,我们传入了一个数组,其中包含了我们之前为每个区域定义的系列数据。

$(**function** () {
    $.plot($("#chart"), [ eas, ecs, lcn, mea, sas, ssf ]);
});

图 2-1 显示了生成的图表。

Flot 仅使用默认选项即可很好地显示静态折线图。 图 2-1. Flot 仅使用默认选项即可很好地显示静态折线图。

看起来我们已经很好地捕获并展示了数据的静态版本,因此我们可以进入下一阶段。

步骤 5:添加控件

现在我们已经有了一个满意的图表,我们可以添加 HTML 控件与其进行交互。在这个例子中,我们的目标相对简单:用户应该能够选择在图表中显示哪些区域。我们将通过一组复选框为每个区域提供这个选项。以下是包含复选框的标记代码。

**<label><input** type="checkbox"**>** East Asia & Pacific**</label>**
**<label><input** type="checkbox"**>** Europe & Central Asia**</label>**
**<label><input** type="checkbox"**>** Latin America & Caribbean**</label>**
**<label><input** type="checkbox"**>** Middle East & North Africa**</label>**
**<label><input** type="checkbox"**>** South Asia**</label>**
**<label><input** type="checkbox"**>** Sub-Saharan Africa**</label>**

你可能会惊讶于我们将 <input> 控件放在 <label> 元素内。虽然这看起来有点不寻常,但几乎总是最好的做法。这样做时,浏览器会将对标签的点击解释为对控件的点击,而如果我们将标签和控件分开,它就会强迫用户点击微小的复选框本身才能产生效果。

在我们的网页中,我们希望将控件放在图表的右侧。我们可以通过创建一个包含 <div> 来实现,并让图表和控件在其中浮动(向左)。在我们调整布局时,最简单的方法是直接在 HTML 标记中添加样式。在生产环境中,你可能希望在外部样式表中定义样式。

**<div** id="visualization"**>**
    **<div** id="chart" style="width:500px;height:333px;float:left"**></div>**
    **<div** id="controls" style="float:left;"**>**
        **<label><input** type="checkbox"**>** East Asia & Pacific**</label>**
        **<label><input** type="checkbox"**>** Europe & Central Asia**</label>**
        **<label><input** type="checkbox"**>** Latin America & Caribbean**</label>**
        **<label><input** type="checkbox"**>** Middle East & North Africa**</label>**
        **<label><input** type="checkbox"**>** South Asia**</label>**
        **<label><input** type="checkbox"**>** Sub-Saharan Africa**</label>**
    **</div>**
**</div>**

我们还应该添加标题和说明,并将所有的 <input> 复选框默认设置为 checked。现在让我们看看图表,确保格式看起来没问题(图 2-2)。

标准 HTML 可以创建图表交互控件。图 2-2. 标准 HTML 可以创建图表交互控件。

现在我们看到控制项相对于图表在图 2-2 中的样子,我们可以验证它们是否对数据和交互模型都有意义。然而,我们的可视化缺少一个关键信息:它没有标明哪条线对应哪个区域。对于静态可视化,我们可以简单地使用 Flot 库向图表添加图例,但这种方法在这里并不理想。你可以在图 2-3 中看到这个问题,因为图例看起来与交互控件相似,容易造成混淆。

Flot 库的标准图例与图表样式不匹配。图 2-3. Flot 库的标准图例与图表样式不匹配。

我们可以通过将图例和交互控件结合起来,消除视觉上的混乱。如果我们添加颜色框来标识图表线条,复选框控件将作为图例。

我们可以使用 HTML <span> 标签和一些样式来添加这些彩色框。这是一个带有内联样式的复选框标记代码。(对于完整的网页实现,可能更好地通过在外部样式表中定义大部分样式来组织。)

**<label** class="checkbox"**>**
    **<input** type="checkbox" checked**>**
    **<span** style="background-color:rgb(237,194,64);height:0.9em;
                 width:0.9em;margin-right:0.25em;display:inline-block;"**/>**
    East Asia & Pacific
**</label>**

除了背景色之外,<span> 还需要一个明确的大小,我们使用 inline-block 值作为 display 属性,以强制浏览器显示该 span,即使它没有内容。如您所见,我们使用 em 而不是像素来定义块的大小。由于 em 会随着文本大小自动缩放,因此即使用户在页面上缩放,颜色块也会与文本标签大小匹配。

在浏览器中快速检查可以验证各种元素是否有效地结合在一起(图 2-4)。

交互控制也可以作为图表元素,如图例图 2-4. 交互控制也可以作为图表元素,如图例。

看起来相当不错,现在我们可以继续进行交互部分的开发了。

步骤 6:定义交互数据结构

现在一般布局看起来不错,我们可以回到 JavaScript。首先,我们需要扩展数据以跟踪交互状态。我们不再将数据存储为简单的数值数组,而是使用一个对象数组。每个对象将包含相应的数据值以及其他属性。

**var** source = [
    { data: eas, show: **true**, color: "#FE4C4C", name: "East Asia & Pacific" },
    { data: ecs, show: **true**, color: "#B6ED47", name: "Europe & Central Asia" },
    { data: lcn, show: **true**, color: "#2D9999",
      name: "Latin America & Caribbean" },
    { data: mea, show: **true**, color: "#A50000",
      name: "Middle East & North Africa" },
    { data: sas, show: **true**, color: "#679A00", name: "South Asia" },
    { data: ssf, show: **true**, color: "#006363", name: "Sub-Saharan Africa" }
];

每个对象都包含一个区域的数据点,同时也为我们提供了一个地方来定义附加属性,包括系列标签和其他状态信息。我们想要跟踪的一个属性是系列是否应该包含在图表中(使用键 show)。我们还需要为每条线指定颜色;否则,Flot 库会根据同时可见的区域数量动态选择颜色,这样我们就无法将颜色与控制图例匹配。

步骤 7:根据交互状态确定图表数据

当我们调用 plot() 来绘制图表时,需要传入一个包含数据系列和每个区域颜色的对象。source 数组包含我们需要的信息,但它还包含其他信息,可能会导致 Flot 行为异常。我们希望向 plot 函数传递一个更简单的对象。例如,东亚与太平洋系列将这样定义:

{
    data:  eas,
    color: "#E41A1C"
}

我们还希望确保只显示用户选择的区域数据。这可能只是完整数据集的一个子集。这两项操作——将数组元素转换(在这种情况下,转换为更简单的对象)和过滤数组以获取子集——是可视化中非常常见的需求。幸运的是,jQuery 提供了两个实用函数,使得这两个操作变得非常简单:$.map()$.grep()

.grep().map() 都接受两个参数。第一个参数是一个数组,或者更准确地说,是一个“类似数组”的对象。它可以是一个 JavaScript 数组,也可以是另一个看起来并且像数组一样工作的 JavaScript 对象。(这里有技术上的区别,但我们无需担心。)第二个参数是一个对数组元素逐一操作的函数。对于 .grep(),该函数返回 truefalse,以相应地过滤掉元素。对于 .map(),该函数返回一个转换后的对象,替换数组中的原始元素。图 2-5 显示了这些函数如何将初始数据转换为最终的数据数组。

jQuery 库提供了帮助转换和过滤数据的实用函数。图 2-5. jQuery 库提供了帮助转换和过滤数据的实用函数。

一步一步来看,以下是如何从响应中过滤掉无关数据的过程。我们使用 .grep() 来检查源数据中的 show 属性,以便返回一个只包含 show 设置为 true 的对象的数组。

$.grep(
    source,
    **function** (obj) { **return** obj.show; }
)

以下是如何转换元素以保留相关属性:

$.map(
    source,
    **function** (obj) { **return** { data: obj.data, color: obj.color }; }
)

不需要将这些步骤分开。我们可以将它们组合成一个简洁的表达式,如下所示:

$.map(
    $.grep(
        source,
        **function** (obj) { **return** obj.show; }
    ),
    **function** (obj) { **return** { data: obj.data, color: obj.color }; }
)

该表达式进而将输入数据提供给 Flot 的 plot() 函数。

第 8 步:使用 JavaScript 添加控件

现在我们的新数据结构可以提供图表输入,让我们用它来将复选框控件也添加到页面中。jQuery 的 .each() 函数是一种方便的方式,可以遍历区域数组。它的参数包括一个对象数组和一个在数组中的每个对象上执行的函数。该函数有两个参数,分别是数组索引和数组对象。

$.each(source, **function**(idx, region) {
    **var** input = $("<input>").attr("type","checkbox").attr("id","chk-"+idx);
    **if** (region.show) {
        $(input).prop("checked",**true**);
    }
    **var** span = $("<span>").css({
        "background-color": region.color,
        "display":          "inline-block",
        "height":           "0.9em",
        "width":            "0.9em",
        "margin-right":     "0.25em",
    });
    **var** label = $("<label>").append(input).append(span).append(region.name);
    $("#controls").append(label);
});

在迭代函数中,我们做了四件事。首先,我们创建了复选框 <input> 控件。如你所见,我们给每个控件分配了一个唯一的 id 属性,组合了 chk- 前缀和源数组索引。如果图表显示该区域,则控件的 checked 属性设置为 true。接着,我们为颜色块创建了一个 <span> 元素。我们使用 css() 函数设置了所有样式,包括该区域的颜色。第三个元素是我们在函数中创建的 <label> 元素。我们将复选框 <input> 控件、颜色块 <span> 和区域名称添加到该元素中。最后,我们将 <label> 元素添加到文档中。

请注意,我们没有直接将中间元素(如 <input><span>)添加到文档中。相反,我们使用局部变量构建这些元素。然后,我们将局部变量组装成最终的完整 <label> 并将其添加到文档中。这种方法显著提高了网页的性能。每次 JavaScript 代码向文档中添加元素时,网页浏览器都需要重新计算页面的外观。对于复杂的页面来说,这可能需要一些时间。通过在将元素添加到文档之前先进行组装,我们只需要强制浏览器为每个区域执行一次该计算。(你还可以通过将所有区域合并为一个局部变量,并只将该单个局部变量添加到文档中来进一步优化性能。)

如果我们将用来绘制图表的 JavaScript 与用来创建控件的 JavaScript 结合起来,我们只需要一个骨架的 HTML 结构。

**<div** id="visualization"**>**
    **<div** id="chart" style="width:500px;height:333px;float:left"**></div>**
    **<div** id="controls" style="float:left;"**>**
        **<p>**Select Regions to Include:**</p>**
    **</div>**
**</div>**

我们的奖励是图 2-6 中的可视化——与图 2-4 中显示的相同——但这一次我们使用 JavaScript 动态创建了它。

设置图表选项确保数据与图例匹配。图 2-6. 设置图表选项确保数据与图例匹配。

第 9 步:响应交互控件

当然,我们还没有添加任何交互功能,但我们快完成了。我们的代码只需要监听控件的点击事件,并适当地重新绘制图表。由于我们方便地为每个复选框设置了以 chk- 开头的 id 属性,所以很容易监听到正确的事件。

$("input[id^='chk-']").click(**function**(ev) {
    *// Handle the click*
})

当代码检测到点击时,它应该判断点击了哪个复选框,切换数据源的 show 属性,并重新绘制图表。我们可以通过跳过事件目标 id 属性中的四个字符的 chk- 前缀来找到具体的区域。

idx = ev.target.id.substr(4);
source[idx].show = !source[idx].show

重新绘制图表需要对 plot() 返回的图表对象进行几次调用。我们重置数据,然后告诉库重新绘制图表。

plotObj.setData(
    $.map(
        $.grep(source, **function** (obj) { **return** obj.show; }),
        **function** (obj) { **return** { data: obj.data, color: obj.color }; }
    )
);
plotObj.draw();

就这样。我们最终得到了一个完全交互式的地区国内生产总值可视化,如图 2-7 所示。

交互式图表让用户控制可视化效果。图 2-7. 交互式图表让用户控制可视化效果。

我们创建的可视化比静态图表更有效地吸引用户。用户仍然可以看到整体情况,但交互控件让他们能够关注对他们来说特别重要或有趣的数据方面。

这种实现仍然存在潜在的问题。两个数据集(欧洲和东亚与太平洋)主导了图表。当用户取消选择这些区域时,剩余的数据被限制在图表的底部,图表的大部分区域被浪费。你可以通过每次绘制图表时重新调整图表的比例来解决这个问题。为此,你需要在调用 plotObj.draw() 之前调用 plotObj.setupGrid()。另一方面,用户可能会觉得这种持续的重新缩放令人不安,因为它改变了整个图表,而不仅仅是他们选择的区域。在下一个示例中,我们将通过让用户完全控制两个轴的缩放来解决这类问题。

在图表上缩放

到目前为止,我们已经通过让用户选择显示哪些数据集,为用户提供了一些交互。但在许多情况下,你可能希望给他们更多控制,特别是当你展示大量数据而细节难以辨别时。如果用户看不到他们需要的细节,那么我们的可视化就失败了。幸运的是,我们可以通过让用户检查数据中的细节来避免这个问题。一种方法是允许用户在图表上进行缩放。

尽管 Flot 库在最基本的形式下不支持缩放,但至少有两个库扩展可以添加此功能:选择 插件和 导航 插件。导航插件有点像 Google 地图。它在图表的一个角落添加了一个类似指南针的控制,提供给用户用于平移或缩放显示的箭头和按钮。然而,这种界面对图表并不是特别有效。用户无法精确控制图表的平移或缩放量,这使得他们难以预见操作的效果。

选择插件提供了一个更好的界面。用户只需拖动鼠标选择他们想要缩放的图表区域。这种操作的效果更加直观,用户可以根据需要精确控制这些操作。然而,该插件确实有一个显著的缺点:它不支持触摸界面。

在本示例中,我们将介绍如何通过选择插件支持缩放的步骤。当然,适用于你自己网站和可视化的最佳方法会因情况而异。

步骤 1:准备页面

由于我们使用的是相同的数据,准备工作大部分与上一个示例相同。

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
     **</head>**
     **<body>**
       *<!-- Content goes here -->*
       *<!--[if lt IE 9]><script src="js/excanvas.min.js"></script><![endif]-->*
       **<script** src="//cdnjs.cloudflare.com/ajax/libs/jquery/1.8.3/jquery.min.js"**>**
       **</script>**
       **<script** src="//cdnjs.cloudflare.com/ajax/libs/flot/0.7/jquery.flot.min.js"**>**
       **</script>**
➊     **<script** src="js/jquery.flot.selection.js"**></script>**
     **</body>**
   **</html>**

如你所见,我们确实需要将选择插件添加到页面中。它在常见的 CDN 上不可用,因此我们将其托管在自己的服务器上,如 ➊ 所示。

步骤 2:绘制图表

在添加任何交互性之前,让我们回到一个基本的图表。这一次,我们将在图表内部添加一个图例,因为我们不会在图表旁边包含复选框。

$(**function** () {
    $.plot($("#chart") [
        { data: eas, label: "East Asia & Pacific" },
        { data: ecs, label: "Europe & Central Asia" },
        { data: lcn, label: "Latin America & Caribbean" },
        { data: mea, label: "Middle East & North Africa" },
        { data: sas, label: "South Asia" },
        { data: ssf, label: "Sub-Saharan Africa" }
    ], {legend: {position: "nw"}});
});

在这里,我们调用了 jQuery 扩展plot(来自 Flot 库),并传递了三个参数。第一个参数指定了应该包含图表的 HTML 元素,第二个参数提供了数据,数据是一个数据系列数组。这些系列包含了我们之前定义的区域,以及一个标识每个系列的标签。最后一个参数指定了图表的选项。为了简化示例,我们只包括一个选项,它告诉 Flot 将图例放置在图表的左上(西北)角。

图 2-8 显示了生成的图表。

大多数交互式图表的起点是一个好的静态图表。 图 2-8. 大多数交互式图表的起点是一个好的静态图表。

看起来我们已经成功地静态展示了数据,因此可以进入下一个阶段。

第 3 步:准备数据以支持交互

现在我们有了一个可工作的静态图表,我们可以规划如何支持交互。作为该支持的一部分,并且为了方便起见,我们将所有传递给plot()的参数存储在本地变量中。

➊ **var** $el = $("#chart"),
➋     data = [
           { data: eas, label: "East Asia & Pacific" },
           { data: ecs, label: "Europe & Central Asia" },
           { data: lcn, label: "Latin America & Caribbean" },
           { data: mea, label: "Middle East & North Africa" },
           { data: sas, label: "South Asia" },
           { data: ssf, label: "Sub-Saharan Africa" }
       ],
➌     options = {legend: {position: "nw"}};

➍ **var** plotObj = $.plot($el, data, options);

在我们调用plot()之前,我们创建了变量$el ➊、data ➋和options ➌。我们还需要在 ➍ 保存从plot()返回的对象。

第 4 步:准备接受交互事件

我们的代码还需要准备处理交互事件。选择插件通过触发自定义的plotselected事件来传达用户的操作,这些事件作用于包含图表的元素。为了接收这些事件,我们需要一个函数,它期望两个参数——标准的 JavaScript 事件对象和一个包含选择详细信息的自定义对象。我们稍后会讨论如何处理该事件。现在我们先关注如何为它做准备。

$el.on("plotselected", **function**(ev, ranges) {
    *// Handle selection events*
});

jQuery 的.on()函数将一个函数分配给一个任意事件。事件可以是标准的 JavaScript 事件,如click,也可以是我们正在使用的自定义事件。感兴趣的事件是.on()的第一个参数。第二个参数是处理该事件的函数。如前所述,它也接受两个参数。

现在,我们可以考虑当函数接收到事件时,我们希望执行的操作。ranges参数包含xaxisyaxis对象,它们包含关于plotselected事件的信息。在这两个对象中,fromto属性指定了用户选择的区域。为了缩放到该选择区域,我们只需使用这些范围来重新绘制图表的坐标轴。

为了重新绘制图表并指定轴,我们需要将新的选项传递给plot()函数,但我们希望保留已经定义的选项。jQuery 的.extend()函数为我们提供了完美的工具来完成这个任务。该函数合并 JavaScript 对象,使得结果包含每个对象中的所有属性。如果对象中可能包含其他对象,我们需要告诉 jQuery 在执行合并时使用“深度”模式。以下是完整的plot()调用,我们将其放入plotselected事件处理程序中。

plotObj = $.plot($el, data,
    $.extend(**true**, {}, options, {
        xaxis: { min: ranges.xaxis.from, max: ranges.xaxis.to },
        yaxis: { min: ranges.yaxis.from, max: ranges.yaxis.to }
    })
);

当我们使用.extend()时,第一个参数(true)请求深度模式,第二个参数指定起始对象,后续的参数指定要合并的其他对象。我们从一个空对象({})开始,合并常规选项,然后进一步合并缩放图表的轴选项。

第 5 步:启用交互

由于我们在页面上包含了选择插件库,激活交互变得非常简单。我们只需在调用plot()时添加一个额外的selection选项。其mode属性指示图表将支持的选择方向。可能的值包括"x"(仅限 x 轴)、"y"(仅限 y 轴)或"xy"(同时支持两个轴)。以下是我们希望使用的完整options变量。

**var** options = {
    legend: {position: "nw"},
    selection: {mode: "xy"}
};

有了这个添加功能,我们的图表现在是交互式的。用户可以缩放以查看任何他们想要的细节。不过有一个小问题:我们的可视化并没有提供一种方式让用户缩小视图。显然,我们不能使用选择插件来缩小视图,因为那需要用户选择当前图表区域之外的内容。相反,我们可以在页面上添加一个按钮来重置缩放级别。

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
     **</head>**
     **<body>**
       **<div** id="chart" style="width:600px;height:400px;"**></div>**
➊     **<button** id="unzoom"**>**Reset Zoom**</button>**
       *<!--[if lt IE 9]><script src="js/excanvas.min.js"></script><![endif]-->*
       **<script** src="//cdnjs.cloudflare.com/ajax/libs/jquery/1.8.3/jquery.min.js"**>**
       **</script>**
       **<script** src="//cdnjs.cloudflare.com/ajax/libs/flot/0.7/jquery.flot.min.js"**>**
       **</script>**
       **<script** src="js/jquery.flot.selection.js"**></script>**
     **</body>**
   **</html>**

你可以在标记中看到按钮的位置在 ➊;它就在包含图表的<div>之后。

现在我们只需要添加代码来响应用户点击按钮的操作。幸运的是,这段代码非常简单。

$("#unzoom").click(**function**() {
    plotObj = $.plot($el, data, options);
});

在这里,我们使用 jQuery 设置了一个点击处理程序,并使用原始选项重新绘制图表。我们不需要任何事件数据,因此我们的事件处理函数甚至不需要参数。

这样我们就得到了一个完整的交互式可视化。用户可以缩放到任何细节级别,并通过点击一次恢复原始缩放。你可以在图 2-9 中看到交互效果。

交互式图表让用户专注于与他们需求相关的数据。图 2-9. 交互式图表让用户专注于与他们需求相关的数据。

图 2-10 展示了用户缩放后看到的内容。

用户可以缩放到特别感兴趣的部分。图 2-10. 用户可以缩放到特别感兴趣的部分。

如果你尝试这个示例,你会很快发现用户不能选择包括图例在内的图表区域。这对于你的可视化来说可能没问题,但如果不行,最简单的解决方法是创建你自己的图例,并将其放置在图表画布外面,就像我们在本章第一个示例中所做的那样。

跟踪数据值

我们使可视化交互式的一个主要原因是为了让用户控制他们查看数据的方式。我们可以展示数据的“整体视图”,但我们并不想阻止用户深入挖掘细节。然而,通常情况下,这会迫使用户做出“二选一”的选择:他们可以看到整体视图,或者可以看到详细的图像,但不能同时看到两者。这个示例展示了一种替代方法,使用户能够同时看到整体趋势和具体细节。为此,我们利用鼠标作为输入设备。当用户的鼠标悬停在图表的某一部分时,我们的代码会叠加显示与该部分相关的细节。

这种方法确实有一个显著的限制:它仅在用户有鼠标时有效。如果你考虑使用这种技术,请注意,触摸屏设备上的用户将无法利用互动功能;他们只能看到静态图表。

由于简单的 GDP 数据并不适合本示例中的方法,我们将可视化世界银行的另一组稍微不同的数据。这次我们将查看出口占 GDP 的百分比。让我们从考虑一个简单的折线图开始,见图 2-11,该图展示了每个世界地区的数据。

在单个图表上绘制多个数据集可能会让用户感到困惑。图 2-11. 在单个图表上绘制多个数据集可能会让用户感到困惑。

这个图表有几个不足之处。首先,许多系列的值相似,导致一些图表的线条互相交叉。这种交叉使得用户难以紧跟单一系列的变化,去查看详细的趋势。其次,用户很难在同一时间点上比较所有地区的具体数值。大多数图表库,包括 Flot,都提供了当用户将鼠标悬停在图表上时显示数值的选项,但这种方式一次只显示一个数值。我们希望给用户提供一个机会,能够比较多个地区的数值。

在本示例中,我们将采用两阶段方法来解决这两个问题。首先,我们将把可视化从一个包含多个系列的单一图表更改为多个图表,每个图表只包含一个系列。这样可以将每个地区的数据隔离开来,更容易看到某一特定地区的趋势。然后,我们将添加一个跨所有图表的高级鼠标追踪功能。该功能允许用户同时查看所有图表中的单个数值。

步骤 1:预留一个<div>元素来承载图表

在我们的文档中,我们需要创建一个<div>元素,用来包含我们将要构建的图表。这个元素不会直接包含图表;相反,我们将在其中放置其他<div>元素,每个<div>将包含一个图表。

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
     **</head>**
     **<body>**
➊     **<div** id="charts"**></div>**
       *<!--[if lt IE 9]><script src="js/excanvas.min.js"></script><![endif]-->*
       **<script** src="//cdnjs.cloudflare.com/ajax/libs/jquery/1.8.3/jquery.min.js"**>**
       **</script>**
       **<script** src="//cdnjs.cloudflare.com/ajax/libs/flot/0.7/jquery.flot.min.js"**>**
       **</script>**
     **</body>**
   **</html>**

"charts" <div>元素在➊处添加。我们在这里还包括了所需的 JavaScript 库,和之前的示例一样。

我们将使用 JavaScript 创建图表的<div>元素。这些元素必须有明确的高度和宽度,否则 Flot 将无法构建图表。你可以在 CSS 样式表中指定元素的大小,或者在创建<div>时直接定义(如下例所示)。这将创建一个新的<div>,设置其宽度和高度,保存其引用,然后将其附加到文档中已经存在的包含<div>中。

$.each(exports, **function**(idx,region) {
    **var** div = $("<div>").css({
        width: "600px",
        height: "60px"
    });
    region.div = div;
    $("#charts").append(div);
});

为了遍历地区数组,我们使用了 jQuery 的.each()函数。该函数接受两个参数:一个对象数组(exports)和一个函数。它一次迭代数组中的一个对象,调用该函数,并将单个对象(region)及其索引(idx)作为参数传递。

步骤 2:准备数据

我们将在下一节看到如何直接从世界银行的 Web 服务获取数据,但目前我们保持简单,假设我们已经下载并将数据格式化为 JavaScript 格式。(再次强调,这里只展示了部分代码,书中的源代码包含了完整的数据集。)

**var** exports = [
    { label: "East Asia & Pacific",
      data: [[1960,13.2277],[1961,11.7964], *// Data continues...*
    { label: "Europe & Central Asia",
      data: [[1960,19.6961],[1961,19.4264], *// Data continues...*
    { label: "Latin America & Caribbean",
      data: [[1960,11.6802],[1961,11.3069], *// Data continues...*
    { label: "Middle East & North Africa",
      data: [[1968,31.1954],[1969,31.7533], *// Data continues...*
    { label: "North America",
      data: [[1960,5.9475],[1961,5.9275], *// Data continues...*
    { label: "South Asia",
      data: [[1960,5.7086],[1961,5.5807], *// Data continues...*
    { label: "Sub-Saharan Africa",
      data: [[1960,25.5083],[1961,25.3968], *// Data continues...*
];

exports数组包含每个地区的一个对象,每个对象包含一个标签和一个数据系列。

步骤 3:绘制图表

由于每个图表的<div>元素已经在我们的页面上就位,我们可以使用 Flot 的plot()函数来绘制图表。该函数接受三个参数:包含元素(我们刚才创建的元素)、数据和图表选项。首先,让我们看一下没有任何装饰——例如标签、网格或勾选标记——的图表,确保数据以我们期望的方式展示。

$.each(exports, **function**(idx,region) {
    region.plot = $.plot(region.div, [region.data], {
        series: {lines: {fill: **true**, lineWidth: 1}, shadowSize: 0},
        xaxis: {show: **false**, min:1960, max: 2011},
        yaxis: {show: **false**, min: 0, max: 60},
        grid:  {show: **false**},
    });
});

上述代码使用了几个plot()选项,去除了图表的多余部分,并按照我们希望的方式设置了坐标轴。我们逐一来看每个选项。

  • series。告诉 Flot 我们希望如何绘制数据系列。在我们的例子中,我们想要一个线形图(这是默认类型),但是我们希望填充从线到 x 轴的区域,所以我们将fill设置为true。这个选项会创建一个区域图,而不是线形图。因为我们的图表非常短,区域图可以使数据更加可见。出于同样的原因,我们希望线条本身尽可能小,以便匹配,因此我们将lineWidth设置为1(像素),同时通过将shadowSize设置为0来去除阴影。

  • xaxis。定义 x 轴的属性。我们不想在这些图表中包含 x 轴,所以我们将show设置为false。然而,我们确实需要明确设置轴的范围。如果不这样做,Flot 会自动创建一个,使用每个系列的范围。由于我们的数据在所有年份中的值并不一致(例如,中东和北非数据集在 1968 年之前没有数据),我们需要确保 Flot 在所有图表中使用完全相同的 x 轴范围,因此我们指定了从19602011的范围。

  • yaxis。与xaxis选项类似。我们不想显示 y 轴,但我们确实需要指定一个明确的范围,以确保所有图表的一致性。

  • grid。告诉 Flot 如何在图表中添加网格线和勾选标记。目前,我们不希望图表中有任何额外的内容,所以我们通过将show设置为false来完全关闭网格。

我们可以在图 2-12 中检查结果,确保图表如我们所愿地显示出来。

将单独的数据集分成多个图表可以更容易地看到每个数据集的细节。图 2-12。将单独的数据集分成多个图表可以更容易地看到每个数据集的细节。

接下来,我们转向图表的装饰。显然,我们缺少每个区域的标签,但添加它们需要一些小心。你可能首先想到的是将图例与每个图表一起包含在同一个<div>中。然而,如果我们能将所有图表——并且仅仅是图表——放在各自的<div>中,Flot 的事件处理将会更好。因此,这将需要对我们的标记进行一些重构。我们将创建一个包装<div>,然后将图表和图例的单独<div>放置在其中。我们可以使用 CSS 的float属性来将它们并排放置。

**<div** id="charts-wrapper"**>**
    **<div** id="charts" style="float:left;"**></div>**
    **<div** id="legends" style="float:left;"**></div>**
    **<div** style="clear:both;"**></div>**
**</div>**

当我们创建每个图例时,我们必须确保它的高度与图表完全一致。由于我们明确设置了这两个值,因此这并不难做到。

$.each(exports, **function**(idx,region) {
    **var** legend = $("<p>").text(region.label).css({
        "height":        "17px",
        "margin-bottom": "0",
        "margin-left":   "10px",
        "padding-top":   "33px"
    });
    $("#legends").append(legend);
});

我们再次使用.each,这次是将每个区域的图例添加到legends元素中。

现在我们想要添加一个贯穿所有图表的连续垂直网格线。因为这些图表是堆叠的,只要我们能够去除图表之间的任何边框或外边距,单个图表中的网格线就可以显示为一条连续的线。实现这一点需要几个plot()选项,如下所示。

$.plot(region.div, [region.data], {
    series: {lines: {fill: **true**, lineWidth: 1}, shadowSize: 0},
    xaxis:  {show: **true**, labelHeight: 0, min:1960, max: 2011,
             tickFormatter: **function**() {**return** "";}},
    yaxis:  {show: **false**, min: 0, max: 60},
    grid:   {show: **true**, margin: 0, borderWidth: 0, margin: 0,
             labelMargin: 0, axisMargin: 0, minBorderMargin: 0},
});

我们通过将grid选项的show属性设置为true来启用网格。然后,通过将各种宽度和边距设置为0来去除所有边框和内边距。为了显示垂直线,我们还需要启用 x 轴,因此我们也将其show属性设置为true。但是,我们不希望在单个图表上显示任何标签,所以我们将labelHeight指定为0。为了确保没有标签出现,我们还定义了一个tickFormatter()函数,它返回一个空字符串。

我们想要添加的最后一些装饰是位于底部图表下方的 x 轴标签。为了做到这一点,我们可以创建一个没有可见数据的虚拟图表,将该虚拟图表放在底部图表下方,并启用其 x 轴的标签。以下三个部分将创建一个虚拟数据数组,创建一个<div>来容纳虚拟图表,并绘制该虚拟图表。

**var** dummyData = [];
**for** (**var** yr=1960; yr<2012; yr++) dummyData.push([yr,0]);

**var** dummyDiv = $("<div>").css({ width: "600px", height: "15px" });
$("#charts").append(dummyDiv);

**var** dummyPlot = $.plot(dummyDiv, [dummyData], {
    xaxis: {show: **true**, labelHeight: 12, min:1960, max: 2011},
    yaxis: {show: **false**, min: 100, max: 200},
    grid:  {show: **true**, margin: 0, borderWidth: 0, margin: 0,
            labelMargin: 0, axisMargin: 0, minBorderMargin: 0},
});

通过这些额外的装饰,我们在图 2-13 中的图表看起来很棒。

小心地堆叠多个图表可以创建统一图表的外观。图 2-13。小心地堆叠多个图表可以创建统一图表的外观。

第 4 步:实现交互

对于我们的可视化,我们想要跟踪鼠标在任何图表上的悬停情况。Flot 库使得这变得相对容易。plot()函数的grid选项包括hoverable属性,默认值为false。如果将此属性设置为true,Flot 会在鼠标移动到图表区域时触发plothover事件,并将这些事件发送到包含图表的<div>中。如果有代码在监听这些事件,代码就可以对其作出响应。如果使用此功能,Flot 还会高亮显示距离鼠标最近的数据点。我们不希望这种行为,因此我们将通过将autoHighlight设置为false来禁用它。

$.plot(region.div, [region.data], {
    series: {lines: {fill: **true**, lineWidth: 1}, shadowSize: 0},
    xaxis:  {show: **true**, labelHeight: 0, min: 1960, max: 2011,
             tickFormatter: **function**() {**return** "";}},
    yaxis:  {show: **false**, min: 0, max: 60},
    grid:   {show: **true**, margin: 0, borderWidth: 0, margin: 0,
             labelMargin: 0, axisMargin: 0, minBorderMargin: 0,
             hoverable: **true**, autoHighlight: **false**},
});

现在我们已经告诉 Flot 在所有图表上触发事件,你可能会认为我们需要为所有图表设置代码来监听事件。不过,还有一种更好的方法。我们将标记结构化,使得所有的图表——并且只有图表——都位于包含它们的charts <div>中。在 JavaScript 中,如果没有代码在特定的文档元素上监听事件,那么这些事件会自动“冒泡”到包含它们的元素。所以,如果我们只在charts <div>上设置一个事件监听器,就可以捕捉到所有单个图表的plothover事件。我们还需要知道何时鼠标离开图表区域。我们可以使用标准的mouseout事件来捕获这些事件,如下所示:

$("charts").on("plothover", **function**() {
    *// The mouse is hovering over a chart*
}).on("mouseout", **function**() {
    *// The mouse is no longer hovering over a chart*
});

为了响应plothover事件,我们希望在所有图表上显示一条垂直线。我们可以使用一个带边框的<div>元素来构建这条线。为了让它能够移动,我们使用绝对定位。它还需要一个正的z-index值,以确保浏览器将其绘制在图表的上方。标记一开始是隐藏的,display属性为none。由于我们希望将标记放置在包含的<div>内,因此我们将包含的<div>position属性设置为relative

**<div** id="charts-wrapper" style="position:relative;"**>**
    **<div** id="marker" style="position:absolute;z-index:1;display:none;
                            width:1px;border-left: 1px solid black;"**></div>**
    **<div** id="charts" style="float:left;"**></div>**
    **<div** id="legends" style="float:left;"**></div>**
    **<div** style="clear:both;"**></div>**
**</div>**

当 Flot 调用监听plothover事件的函数时,它会传递三个参数:JavaScript 事件对象、鼠标位置(以 x 和 y 坐标表示),以及如果鼠标靠近某个数据点,关于该数据点的信息。在我们的示例中,我们只需要 x 坐标。我们可以将其四舍五入为最接近的整数,从而得到年份。我们还需要知道鼠标相对于页面的位置。如果我们调用任意一个图表对象的pointOffset(),Flot 会为我们计算这一点。注意,第三个参数只有在鼠标靠近实际数据点时才会提供,因此我们可以忽略它。

$("charts").on("plothover", **function**(ev, pos) {
    **var** year = Math.round(pos.x);
    **var** left = dummyPlot.pointOffset(pos).left;
});

一旦我们计算出位置,就可以简单地将标记移动到该位置,确保它覆盖整个包含<div>的高度,并将其显示出来。

   $("#charts").on("plothover", **function**(ev, pos) {
       **var** year = Math.round(pos.x);
       **var** left = dummyPlot.pointOffset(pos).left;
➊     **var** height = $("#charts").height();
       $("#marker").css({
           "top":    0,
➋         "left":   left,
           "width":  "1px",
➌         "height": height
       }).show();
   });

在这段代码中,我们在➊计算标记的高度,在➋设置标记的位置,并在➌设置高度。

我们还需要在mouseout事件上小心处理。如果用户将鼠标移到标记上方,这将触发charts <div>mouseout事件。在这种特殊情况下,我们希望保留标记的显示。为了判断鼠标移动的位置,我们检查事件的relatedTarget属性。只有当relatedTarget不是标记本身时,我们才会隐藏标记。

$("#charts").on("mouseout", **function**(ev) {
    **if** (ev.relatedTarget.id !== "marker") {
        $("#marker").hide();
    }
});

在我们的事件处理过程中,仍然存在一个漏洞。如果用户将鼠标直接移到标记上方,然后完全离开图表区域(而不是离开标记),我们无法捕捉到鼠标不再悬停在图表上的事实。为了捕捉这一事件,我们可以监听标记本身的mouseout事件。无需担心鼠标是否离开标记并重新返回图表区域,因为现有的plothover事件会处理这种情况。

$("#marker").on("mouseout", **function**(ev) {
      $("#marker").hide();
});

我们交互的最后一部分显示了所有图表中与鼠标水平位置相对应的数值。我们可以在创建每个图表时,创建<div>来保存这些数值。因为这些<div>可能会延伸到图表区域之外,所以我们将它们放置在外部的charts-wrapper <div>中。

   $.each(exports, **function**(idx,region) {
       **var** value = $("<div>").css({
           "position":  "absolute",
           "top":       (div.position().top - 3) + "px",
➊         "display":   "none",
           "z-index":   1,
           "font-size": "11px",
           "color":     "black"
       });
       region.value = value;
       $("#charts-wrapper").append(value);
   });

注意,在创建这些<div>时,我们设置了除了左侧位置之外的所有属性,因为左侧位置会随鼠标的变化而变化。我们还将元素的display属性设置为none以隐藏它们,见➊。

在文档中等着我们的<div>中,我们的plothover事件处理程序会设置每个的文本,水平定位它们,并将它们显示在页面上。为了设置文本值,我们可以使用 jQuery 的.grep()函数在数据中搜索匹配的年份。如果没有找到,值<div>的文本将被清空。

$("#charts").on("plothover", **function**(ev, pos) {
    $.each(exports, **function**(idx, region) {
        matched = $.grep(region.data, **function**(pt) { **return** pt[0] === year; });
        **if** (matched.length > 0) {
            region.value.text(year + ": " + Math.round(matched[0][1]) + "%");
        } **else** {
            region.value.text("");
        }
        region.value.css("left", (left+4)+"px").show();
    });
});

最后,我们需要在鼠标离开图表区域时隐藏这些<div>。我们还应该处理鼠标直接移到标记上的情况,就像之前所做的那样。

$("#charts").on("plothover", **function**(ev, pos) {

    *// Handle plothover event*

}).on("mouseout", **function**(ev) {
    **if** (ev.relatedTarget.id !== "marker") {
        $("#marker").hide();
        $.each(exports, **function**(idx, region) {
            region.value.hide();
        });
    }
});

$("#marker").on("mouseout", **function**(ev) {
    $("#marker").hide();
    $.each(exports, **function**(idx, region) {
        region.value.hide();
    });
});

现在我们可以享受我们编码的成果,在图 2-14 中展示。我们的可视化明确了各个地区出口的趋势,并允许用户与图表互动,比较各地区并查看详细数值。

最终的可视化结合了多个图表和鼠标跟踪,更清晰地呈现数据。图 2-14. 最终的可视化结合了多个图表和鼠标跟踪,更清晰地呈现数据。

当用户将鼠标移过图表时,垂直条也会随着移动。对应鼠标位置的数值也会出现在每个图表的标记右侧。这种交互使得比较各地区的数值变得简单且直观。

我们在这个例子中创建的图表类似于小型多图方法,允许用户比较多个数值。在我们的例子中,图表占据了整个页面,但它也可以设计为更大展示中的一个元素,比如一个表格。第三章提供了将图表集成到更大网页元素中的例子。

使用 AJAX 获取数据

本书中的大多数例子强调数据可视化的最终产品:用户看到的图形、图表或图像。但有效的可视化通常需要在幕后做大量的工作。毕竟,像数据可视化这样的有效展示既需要数据,也需要可视化。在这个例子中,我们关注了一种常见的数据访问方法——异步 JavaScript 和 XML,更常见的名称是AJAX。这里的例子详细描述了与世界银行的 AJAX 交互,但这里展示的通用方法和具体技术同样适用于网络上的许多其他数据源。

第一步:理解源数据

通常,处理远程数据时的第一个挑战是理解其格式和结构。幸运的是,我们的数据来自世界银行,并且其网站详细记录了其应用程序编程接口(API)。在这个例子中,我们不会花太多时间讨论细节,因为你可能会使用不同的数据源。不过,快速了解一下是很有帮助的。

第一个需要注意的是,世界银行将世界划分为多个区域。像所有优秀的 API 一样,世界银行 API 允许我们发出查询来获取这些区域的列表。

**http**://api.worldbank.org/regions/?format=json

我们的查询返回完整的列表,格式为 JSON 数组,起始部分如下所示:

 { "page": "1",
    "pages": "1",
    "per_page": "50",
    "total": "22"
  },
  [ { "id": "",
      "code": "ARB",
      "name": "Arab World"
    },
    { "id": "",
      "code": "CSS",
      "name": "Caribbean small states"
    },
    { "id": "",
      "code": "EAP",
      "name": "East Asia & Pacific (developing only)"
    },
    { "id": "1",
      "code": "EAS",
      "name": "East Asia & Pacific (all income levels)"
    },
    { "id": "",
      "code": "ECA",
      "name": "Europe & Central Asia (developing only)"
    },
    { "id": "2",
      "code": "ECS",
      "name": "Europe & Central Asia (all income levels)"
    },

数组中的第一个对象支持通过大量数据集进行分页,这对我们来说目前并不重要。第二个元素是一个包含我们所需信息的数组:区域列表。总共有 22 个区域,但许多区域是重叠的。我们希望从所有区域中选择,这样我们既能包含所有国家,又不会有一个国家出现在多个区域。符合这些标准的区域都方便地标记了一个id属性,因此我们只会选择那些id属性不为null的区域。

第 2 步:通过 AJAX 获取第一层数据

既然你已经了解了数据格式(到目前为止),接下来我们来写一些代码来获取数据。由于我们已经加载了 jQuery,我们将利用它的许多工具。让我们从最简单的实现开始,逐步构建完整的实现。

正如你可能预料到的,$.getJSON()函数将为我们完成大部分工作。使用该函数的最简单方式可能是如下所示:

   $.getJSON(
       "http://api.worldbank.org/regions/",
➊     {format: "json"},
       **function**(response) {
           *// Do something with response*
       }
   );

请注意,我们在查询中添加了format: "json",以告诉世界银行我们希望的数据格式。如果没有这个参数,服务器会返回 XML,而getJSON()并不期望这种格式。

不幸的是,这段代码在当前提供世界银行数据的 Web 服务器上无法正常工作。实际上,这个问题在今天非常常见。正如 Web 上常见的情况,安全问题是导致这种复杂性的原因。请考虑我们正在建立的信息流,见[图 2-15。

我们的服务器(your.web.site.com)向用户发送网页,包括脚本,而这些脚本在用户的浏览器中执行,查询世界银行网站(api.worldbank.com)。图 2-15. 我们的服务器(your.web.site.com)向用户发送网页,包括脚本,而这些脚本在用户的浏览器中执行,查询世界银行网站(api.worldbank.com)。

使用 AJAX 获取数据通常需要三个不同系统的配合。

脚本与世界银行的通信对用户来说是不可见的,因此他们没有机会批准或拒绝交换。在世界银行的情况下,很难想象用户拒绝查询的任何理由,但如果我们的脚本正在访问用户的社交网络资料,或者更严重的是他们的在线银行账户呢?在这种情况下,用户的担忧是有正当理由的。由于通信对用户是不可见的,而且因为浏览器无法猜测哪些通信可能是敏感的,所以浏览器会简单地禁止所有此类通信。这个技术术语叫做同源策略。该策略意味着我们的服务器提供的网页无法直接访问世界银行的 JSON 接口。

一些网站通过在其响应中添加 HTTP 头来解决这个问题。该头部告诉浏览器,任何网页都可以安全地访问这些数据:

**Access-Control-Allow-Origin**: *

不幸的是,截至本文写作时,世界银行尚未实现该头部。这个选项相对较新,因此许多 Web 服务器都没有实现它。因此,为了在同源策略的约束下工作,我们依赖 jQuery 的帮助和一些小小的伎俩。这个技巧依赖于所有浏览器都承认的同源策略的一个例外:第三方 JavaScript 文件。浏览器确实允许网页从第三方服务器请求 JavaScript 文件(毕竟,像 Google Analytics 这样的服务就是通过这种方式工作的)。我们只需要让世界银行的响应数据看起来像是常规的 JavaScript,而不是 JSON。幸运的是,世界银行与我们在这一小小的欺骗上配合得很好。我们只需向请求中添加两个查询参数:

**?format**=jsonP**&**prefix=Getdata

format 参数的值为 jsonP,它告诉世界银行我们希望将响应格式化为带填充的 JSON,这是一种 JSON 的变体,同时也是常规 JavaScript。第二个参数 prefix 告诉世界银行接收数据的函数名称。(没有这个信息,世界银行生成的 JavaScript 将不知道如何与我们的代码进行通信。)这有点复杂,但 jQuery 为我们处理了大部分细节。唯一的难点是,我们必须在传递给 .getJSON() 的 URL 中添加 ?something=?,其中 something 是 Web 服务要求的 JSONP 响应。世界银行期望 prefix,但更常见的值是 callback

现在我们可以编写一些代码,这些代码可以与世界银行及许多其他 Web 服务器一起使用,尽管 prefix 参数是特定于世界银行的。

   $.getJSON(
➊     "http://api.worldbank.org/regions/?prefix=?",
➋     {format: "jsonp"},
       **function**(response) {
           *// Do something with response*
       }
   );

我们在 ➊ 处直接在 URL 中添加了 prefix,并在 ➋ 处将格式更改为 jsonp

JSONP 确实有一个主要的缺点:服务器无法指示错误。这意味着我们应该花更多时间测试和调试任何 JSONP 请求,并且应该时刻警惕服务器的任何更改,这些更改可能导致之前正常工作的代码失败。最终,世界银行将更新其响应中的 HTTP 头(或许在本书出版时就会更新),到时我们可以切换到更健壮的 JSON 格式。

注意

在撰写本文时,世界银行的 API 存在一个严重的 bug。服务器没有保留回调函数的大小写(大写与小写)。本示例的完整源代码包括了针对该 bug 的解决方法,但对于其他服务器,你不太可能需要这个。万一需要,你可以查看源代码中的注释,那里有完整的修复文档。

现在让我们回到代码本身。在前面的代码片段中,我们直接在对 .getJSON() 的调用中定义了一个回调函数。你会在许多实现中看到这种代码结构。这当然是可行的,但如果我们继续沿着这条路走下去,事情很快就会变得非常混乱。在我们开始处理响应之前,已经添加了几层缩进。正如你可以猜到的,一旦我们收到这个初始响应,我们将需要发起更多请求来获取额外的数据。如果我们尝试将代码构建成一个单一的整体块,我们最终会有太多层缩进,以至于根本没有空间放实际的代码。更重要的是,结果将是一个庞大的互联代码块,理解起来非常困难,更不用说调试或扩展了。

幸运的是,jQuery 为我们提供了一个更好的方法工具:$.Deferred 对象。Deferred 对象充当事件的中央调度器和调度器。一旦创建了 Deferred 对象,我们代码的不同部分可以表明它们希望知道事件何时完成,而其他部分则通知事件的状态。Deferred 协调了这些不同的活动,使我们能够将触发和管理事件的方式与处理其后果的方式分开。

让我们来看一下如何使用 Deferred 对象改进我们的 AJAX 请求。我们的主要目标是将事件的启动(AJAX 请求)与处理其后果(处理响应)分开。通过这种分离,我们不需要将成功函数作为请求本身的回调参数。相反,我们将依赖于 .getJSON() 调用返回一个 Deferred 对象这一事实。(从技术上讲,函数返回的是一种限制形式的 Deferred 对象,称为 promise;不过现在这些区别对我们来说并不重要。)我们希望将返回的对象保存在一个变量中。

*// Fire off the query and retain the deferred object tracking it*
deferredRegionsRequest = $.getJSON(
    "http://api.worldbank.org/regions/?prefix=?",
    {format: "jsonp"}
);

这很简单直接。现在,在我们代码的其他部分,我们可以表示我们有兴趣知道 AJAX 请求何时完成。

deferredRegionsRequest.done(**function**(response) {
    *// Do something with response*
});

Deferred 对象的 done() 方法是关键。它指定了一个新函数,每当事件(在这里是 AJAX 请求)成功完成时,我们希望执行该函数。Deferred 对象处理了所有繁琐的细节。特别是,如果在我们通过 done() 注册回调时事件已经完成,Deferred 对象会立即执行该回调。否则,它会等待直到请求完成。我们还可以表示希望知道 AJAX 请求是否失败;对于这个需求,我们使用 fail() 方法而不是 done()。(即使 JSONP 不提供给服务器报告错误的方式,请求本身仍然可能失败。)

deferredRegionsRequest.fail(**function**() {
    *// Oops, our request for region information failed*
});

我们显然已将缩进减少到一个更易管理的级别,但我们也为代码创建了一个更好的结构。发起请求的函数与处理响应的代码是分开的。这种结构更清晰,修改和调试起来也更加容易。

步骤 3:处理第一层数据

现在让我们处理响应。分页信息与我们无关,因此我们可以跳过返回响应中的第一个元素,直接处理第二个元素。我们希望将这个数组分两步进行处理。

  1. 过滤掉数组中与我们无关的元素。在本例中,我们只关心那些 id 属性不为 null 的地区。

  2. 转换数组中的元素,使其仅包含我们关心的属性。在这个例子中,我们只需要 codename 属性。

这听起来可能有些熟悉。事实上,这正是我们在本章第一个例子中需要做的事情。正如我们在那里看到的,jQuery 的 $.map()$.grep() 函数是非常有用的工具。

一步一步来,这里是如何从响应中过滤掉无关数据的方法。

filtered = $.grep(response[1], **function**(regionObj) {
    **return** (regionObj.id !== **null**);
});

这里是如何转换元素,只保留相关属性的方式。既然我们已经在做这个了,不妨去掉世界银行在某些地区名称后附加的括号内容“(所有收入水平)”。我们所有的地区(那些有 id 的地区)都包含所有收入水平,因此这些信息是多余的。

regions = $.map(filtered, **function**(regionObj) {
        **return** {
            code: regionObj.code,
            name: regionObj.name.replace(" (all income levels)","")
        };
    }
);

不必将这些步骤分开。我们可以将它们结合成一个简洁的表达式。

deferredRegionsRequest.done(**function**(response) {
    regions = $.map(
        $.grep(response[1], **function**(regionObj) {
            **return** (regionObj.id !== **null**);
        }),
        **function**(regionObj) {
            **return** {
                code: regionObj.code,
                name: regionObj.name.replace(" (all income levels)","")
            };
        }
    );
});

步骤 4:获取真实数据

到目前为止,当然,我们所能获取的只是地区列表。这并不是我们想要可视化的数据。通常,通过基于 Web 的接口获取实际数据需要(至少)两个请求阶段。第一个请求仅提供后续请求所需的基本信息。在这种情况下,我们想要的实际数据是 GDP,因此我们需要遍历地区列表,并为每个地区检索该数据。

当然,我们不能盲目地发出第二组请求,即请求详细的地区数据。首先,我们必须等到获得地区列表。在第 2 步中,我们通过使用.getJSON()Deferred对象来处理了一个类似的情况,将事件管理与处理分离。我们可以在这里使用相同的技术;唯一的区别是,我们必须创建自己的Deferred对象。

**var** deferredRegionsAvailable = $.Deferred();

稍后,当地区列表可用时,我们通过调用对象的resolve()方法来表示该状态。

deferredRegionsAvailable.resolve();

实际的处理由done()方法处理。

deferredRegionsAvailable.done(**function**() {
    *// Get the region data*
});

获取实际地区数据的代码当然需要地区列表。我们可以将该列表作为全局变量传递,但这会污染全局命名空间。(即使你已经正确命名了你的应用,为什么还要污染你自己的命名空间呢?)这个问题很容易解决。我们提供给resolve()方法的任何参数都会直接传递给done()函数。

让我们先看看整体情况,这样我们就能看到各个部分是如何配合的。

   *// Request the regions list and save status of the request in a Deferred object*
➊ **var** deferredRegionsRequest = $.getJSON(
       "http://api.worldbank.org/regions/?prefix=?",
       {format: "jsonp"}
   );

   *// Create a second Deferred object to track when list processing is complete*
➋ **var** deferredRegionsAvailable = $.Deferred();

   *// When the request finishes, start processing*
➌ deferredRegionsRequest.done(**function**(response) {
       *// When we finish processing, resolve the second Deferred with the results*
➍     deferredRegionsAvailable.resolve(
           $.map(
               $.grep(response[1], **function**(regionObj) {
                   **return** (regionObj.id != "");
               }),
               **function**(regionObj) {
                   **return** {
                       code: regionObj.code,
                       name: regionObj.name.replace(" (all income levels)","")
                   };
               }
           )
       );
   });
   deferredRegionsAvailable.done(**function**(regions) {
➎     *// Now we have the regions, go get the data*
   });

首先,从➊开始,我们请求地区列表。然后,在➋处,我们创建第二个Deferred对象来跟踪响应处理。在从➌开始的代码块中,我们处理初始请求的响应。最重要的是,在➍处,我们解析第二个Deferred对象,表示处理完成。最后,从➎开始,我们可以开始处理响应。

获取每个地区的实际 GDP 数据需要一个新的 AJAX 请求。正如你所料,我们将保存这些请求的Deferred对象,以便在响应可用时处理它们。jQuery 的.each()函数是一个方便的方式,可以遍历地区列表并启动这些请求。

   deferredRegionsAvailable.done(**function**(regions) {
       $.each(regions, **function**(idx, regionObj) {
           regionObj.deferredDataRequest = $.getJSON(
               "http://api.worldbank.org/countries/"
                  + regionObj.code
➊                + "/indicators/NY.GDP.MKTP.CD"
                  + "?prefix=?",
               { format: "jsonp", per_page: 9999 }
           );
       });
   });

每个请求 URL 中➊处的“NY.GDP.MKTP.CD”部分是世界银行的 GDP 数据代码。

只要我们在遍历各个地区,就可以加入处理 GDP 数据的代码。到现在为止,你应该不会对我们创建一个Deferred对象来跟踪处理完成的时机感到惊讶。处理本身将简单地将返回的响应(跳过分页信息后)存储在地区对象中。

   deferredRegionsAvailable.done(**function**(regions) {
       $.each(regions, **function**(idx, regionObj) {
           regionObj.deferredDataRequest = $.getJSON(
               "http://api.worldbank.org/countries/"
                  + regionObj.code
                  + "/indicators/NY.GDP.MKTP.CD"
                  + "?prefix=?",
               { format: "jsonp", per_page: 9999 }
           );
           regionObj.deferredDataAvailable = $.Deferred();
           regionObj.deferredDataRequest.done(**function**(response) {
➊             regionObj.rawData = response[1] || [];
               regionObj.deferredDataAvailable.resolve();
           });
       });
   });

注意,我们还在➊处添加了一个检查,以确保世界银行在响应中实际返回了数据。由于内部错误,它可能返回一个null对象,而不是数据数组。发生这种情况时,我们会将rawData设置为空数组,而不是null

第 5 步:处理数据

现在我们已经请求了真实的数据,几乎可以开始处理它了。还有一个最后的难关要克服,而这个难关是我们熟悉的。我们不能在数据不可用之前开始处理,这就需要定义一个新的Deferred对象,并在数据完成时解析该对象。(现在你应该已经意识到Deferred对象有多么方便了。)

然而,有一个小小的变化。现在我们有多个请求在进行,每个区域一个请求。我们怎么知道所有这些请求何时完成呢?幸运的是,jQuery 提供了一个方便的解决方案,那就是 .when() 函数。该函数接受一组 Deferred 对象,并仅在所有对象都成功时才表示成功。我们只需要将这些 Deferred 对象的列表传递给 .when() 函数。

我们可以使用 .map() 函数组合一个 Deferred 对象数组,但 .when() 期望的是一个参数列表,而不是数组。JavaScript 标准中深藏着一种将数组转换为函数参数列表的技巧。我们不直接调用函数,而是执行 .when() 函数的 apply() 方法。该方法将上下文(this)和数组作为参数传递。

这是创建数组的 .map() 函数。

$.map(regions, **function**(regionObj) {
    **return** regionObj.deferredDataAvailable
**})**

下面是我们如何将其作为参数列表传递给 when()

$.when.apply(**this**,$.map(regions, **function**(regionObj) {
    **return** regionObj.deferredDataAvailable
}));

when() 函数返回它自己的 Deferred 对象,因此我们可以使用我们已经知道的方法来处理它的完成。现在我们终于有了一个完整的解决方案来获取世界银行数据。

在数据安全到手之后,我们现在可以将其强制转换为 Flot 可接受的格式。我们从原始数据中提取 datevalue 属性。我们还必须考虑数据中的缺口。世界银行并不是每个区域每年都有 GDP 数据。当某一年的数据缺失时,它会返回 null 作为 value。我们之前使用过的 .grep().map() 的组合将在这里再次派上用场。

   deferredAllDataAvailable.done(**function**(regions) {
➊     $.each(regions, **function**(idx, regionObj) {
➋         regionObj.flotData = $.map(
➌             $.grep(regionObj.rawData, **function**(dataObj) {
                   **return** (dataObj.value !== **null**);
               }),
➍             **function**(dataObj) {
                   **return** [[
➎                     parseInt(dataObj.date),
➏                     parseFloat(dataObj.value)/1e12
                   ]];
               }
           )
       })
   });

正如你所看到的,我们使用 .each() 函数在 ➊ 遍历区域列表。对于每个区域,我们创建一个供 Flot 库使用的数据对象。(给这个对象命名为 flotData 在 ➋ 并没有什么创意可言。)然后,我们从 ➌ 开始过滤数据,删除任何值为 null 的数据点。创建我们 Flot 数据数组的函数从 ➍ 开始。它的输入是来自世界银行的单个数据对象,并将数据作为二维数据点返回。第一个值是日期,我们在 ➎ 提取为整数,第二个值是 GDP 数据,我们在 ➏ 提取为浮动点数。通过除以 1e12,将 GDP 数据转换为万亿单位。

步骤 6:创建图表

由于我们已经在处理事件的代码和处理结果的代码之间进行了清晰的分离,实际上在创建图表时继续采用这种方法也是合理的。又一个 Deferred 对象在这里创建了这种分离。

   **var** deferredChartDataReady = $.Deferred();

   deferredAllDataAvailable.done(**function**(regions) {
       $.each(regions, **function**(idx, regionObj) {
           regionObj.flotData = $.map(
               $.grep(regionObj.rawData, **function**(dataObj) {
                   **return** (dataObj.value !== **null**);
               }),
               **function**(dataObj) {
                   **return** [[
                       parseInt(dataObj.date),
                       parseFloat(dataObj.value)/1e12
                   ]];
               }
           )
       })
➊     deferredChartDataReady.resolve(regions);
   });
   deferredChartDataReady.done(**function**(regions) {
       *// Draw the chart*
   });

在这里,我们已经将前面的代码片段封装在 Deferred 对象的处理过程中。一旦所有数据处理完毕,我们就在 ➊ 处解析该 Deferred 对象。

整个过程让人想起青蛙在池塘中的荷叶之间跳跃。荷叶是处理步骤,而Deferred对象则是它们之间的桥梁(图 2-16)。

Deferred 对象有助于将每个代码片段隔离在自己的荷叶上。图 2-16. Deferred对象有助于将每个代码片段隔离在自己的荷叶上。

这种方法的真正好处在于它的关注点分离。每个处理步骤都独立于其他步骤。如果任何步骤需要更改,则无需查看其他步骤。每个荷叶实际上都保持自己独立的岛屿,而不必担心池塘的其他部分。

一旦我们完成最后一步,就可以使用本章其他示例中的任何一种或多种技术来绘制图表。再次强调,.map() 函数可以轻松地从区域数据中提取相关信息。以下是一个基本示例:

deferredChartDataReady.done(**function**(regions) {
    $.plot($("#chart"),
        $.map(regions, **function**(regionObj) {
            **return** {
                label: regionObj.name,
                data: regionObj.flotData
            };
        })
        ,{ legend: { position: "nw"} }
    );
});

我们的基本图表现在直接从世界银行获取数据。我们不再需要手动处理数据,而且每当世界银行更新数据时,我们的图表也会自动更新(图 2-17)。

通过 AJAX,我们可以在用户的浏览器中从另一个网站绘制实时数据图表。图 2-17. 通过 AJAX,我们可以在用户的浏览器中从另一个网站绘制实时数据图表。

在这个示例中,您已经看到如何访问世界银行的应用程序接口。相同的方法也适用于许多其他提供互联网上数据的组织。事实上,今天有如此多的数据源可供使用,以至于可能很难跟踪它们所有。

这里有两个有用的网站,它们作为一个集中库,提供可以访问的公共和私人 API:

许多政府也提供了可用数据和 API 的目录。例如,美国将其资源集中在 Data.gov 网站上 (www.data.gov/)。

这个示例重点讲解了 AJAX 交互,因此生成的图表是一个简单的静态折线图。可以将本章其他示例中描述的任何交互添加到图表中,以增强可视化的互动性。

总结

正如本章中的示例所示,我们不必满足于在网页上展示静态图表。通过一些 JavaScript 代码,可以让图表生动起来,允许用户与其互动。这些互动使用户能够看到数据的“全貌”,并且在同一页面上,深入查看对他们最有趣和相关的具体内容。我们已经考虑了让用户选择在图表上显示哪些数据系列、放大图表特定区域,并通过鼠标探索数据的细节,同时不失去对整体视图的掌控。我们还探讨了如何通过 AJAX 和异步编程直接从数据源获取互动数据。

第三章:在页面上整合图表

你可能会期望网页上的数据可视化占据非常显著的位置,甚至可能占据整个网页。但这种方法并不总是合适的。最好的可视化之所以有效,是因为它们帮助用户理解数据,而不是因为它们在页面上“看起来漂亮”。

有些数据可能足够直观,可以不加背景直接呈现,但有意义的数据通常不会如此简单。如果我们的展示需要上下文,那么它的可视化很可能会与其他内容一起出现在页面上。在设计网页时,我们应该注意平衡任何单一组件与整个页面的关系。如果一个可视化并不能讲述全部故事,那么它就不应该占据页面上所有(甚至大部分)的空间。然而,减少传统图表所需空间可能是一个挑战,毕竟,图表中有坐标轴、标签、标题、图例等要素需要放置。

爱德华·塔夫特在他开创性的著作《定量信息的视觉展示》(Graphics Press,1983 年)中讨论了这个问题,并提出了一个他称之为 sparklines(火花图)的新颖解决方案。火花图是被简化到最基本要素的图表,展示时没有我们通常在图表中看到的那些附加元素。火花图能够在极小的空间内展示大量信息,甚至可以在句子中间插入一个图表。无需“见下图”或“点击查看更大视图”。塔夫特早期的一个例子展示了一个医疗病人的血糖水平;图 3-1 展示了这一示例的复原图。

塔夫特的经典火花图例展示了一个小空间内的丰富信息。图 3-1. 塔夫特的经典火花图例展示了一个小空间内的丰富信息。

在仅 154×20 像素的空间中,我们展示了患者当前的血糖水平、超过两个月的趋势、高低值以及正常值范围。这种高信息密度使得火花图在空间有限的情况下尤为有效——可以嵌入文本段落中、作为表格单元格或信息仪表板的一部分。当然,火花图也有一些缺点。它们无法提供像带有坐标轴和标签的完整图表那样的细节。它们也不支持显著的交互性,因此无法为用户提供选择数据或缩放查看细节的灵活性。但对于许多可视化来说,这些并不是主要问题。此外,正如我们在本章示例中所看到的,Web 让我们有机会以打印中无法实现的方式增强火花图。虽然有一些 JavaScript 库和工具包可以用来创建火花图,但我们将重点介绍其中最流行的一个:jQuery sparklines (omnipotent.net/jquery.sparkline/)。顾名思义,这个开源库是 jQuery 的扩展。本章的示例将深入探讨如何使用这些工具将密集型可视化集成到你的网页中。以下是你将学到的内容:

  • 如何为直接嵌入文本的火花图创建经典火花图

  • 如何结合多个火花图展示比较

  • 如何为火花图添加附加细节

  • 如何创建复合图表

  • 如何响应页面上的点击事件

  • 如何实时更新图表

创建经典火花图

正如后面的示例将展示的那样,sparklines 库既灵活又强大,我们可以在许多不同的上下文中使用它。作为开始,我们将使用该库来创建一个与 Edward Tufte 最早定义的火花图完全相同的图表。这个过程非常简单,只需四个步骤。

步骤 1:包含所需的 JavaScript 库

由于我们使用 jQuery sparklines 库来创建图表,我们需要在网页中引入该库以及 jQuery。jQuery 和 sparklines 都可以通过公共 CDN 获取。在本示例(以及本章中的其他示例)中,我们将使用 CloudFlare CDN。关于使用 CDN 的优缺点,请参见 步骤 1:包含所需的 JavaScript 库。

这是我们开始时的骨架:

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
     **</head>**
     **<body>**
       *<!-- Content goes here -->*
➊     *<!--[if lt IE 9]><script src="js/excanvas.min.js"></script><![endif]-->*
       **<script** src="//cdnjs.cloudflare.com/ajax/libs/jquery/1.8.3/jquery.min.js"**>**
       **</script>**
       **<script** src="//cdnjs.cloudflare.com/ajax/libs/jquery-sparklines/2.0.0/
   jquery.sparkline.min.js"**></script>**
     **</body>**
   **</html>**

如你所见,我们将 JavaScript 库包含在文档的最后。这种方法允许浏览器在等待服务器提供 JavaScript 库的同时,加载文档的所有 HTML 标记并开始布局页面。

除了 jQuery 库外,sparklines 还依赖于 HTML 的 canvas 特性。由于 Internet Explorer 在版本 9 之前不支持 canvas,因此我们在 ➊ 处使用了一些特殊的标记,以确保 IE 8 及以下版本能加载额外的库(excanvas.min.js),就像我们在第二章中所做的那样。

步骤 2:为 Sparkline 创建 HTML 标记

因为我们将 sparkline 图表与其他元素紧密集成,所以我们简单地使用 <span> 标签来承载我们的可视化 HTML 标记,而不是使用 <div>。除了图表本身,我们还将最终值和标签作为标准 HTML 包含进来。以下是葡萄糖 sparklines 的 HTML 代码:

**<p>**
  **<span** class="sparkline"**>**
    170,134,115,128,168,166,122,81,56,39,97,114,114,130,151,
    184,148,145,134,145,145,145,143,148,224,181,112,111,129,
    151,131,131,131,114,112,112,112,124,187,202,200,203,237,
    263,221,197,184,185,203,290,330,330,226,113,148,169,148,
    78,96,96,96,77,59,22,22,70,110,128
  **</span>**
  128 Glucose
**</p>**

与其他可视化相比,我们的 sparkline 图表有两个不同寻常的特点。

  • 我们将数据直接包含在 HTML 中,而不是在创建图表的 JavaScript 中。

  • 图表的 <span> 没有唯一的 id 属性。

这两个差异是可选的;我们可以像其他可视化一样,通过将数据传递给 JavaScript 函数并通过唯一的 id 标识其容器来构建图表。然而,对于 sparklines(小型图表),我们在这里使用的方法通常更为合适。通过直接在 HTML 中包含图表数据,我们可以轻松查看数据与页面上其他内容的关系。例如,很明显,我们图表的最终值(128)与我们用于标签的值相同。如果我们犯了错误,使用了不同的标签值,错误就更容易被发现和修正。使用通用的 class 来表示所有 sparklines,而不是唯一的 id,简化了我们使用库在一页上创建多个图表的方式。如果使用唯一的 id,我们每个图表都必须调用一次库函数。而使用通用的 class,我们只需调用一次库函数即可创建多个图表。这在网页中包含许多 sparklines 时尤其有用。

步骤 3:绘制 Sparkline

现在我们已经包含了必要的库并设置了 HTML,绘制图表变得异常简单。事实上,一行 JavaScript 代码就足够了。我们只需使用 jQuery 选择包含元素($(".sparkline")),然后调用 sparklines 插件。

$(**function**() {
    $(".sparkline").sparkline();
}

如你在图 3-2 中看到的,sparklines 库根据我们的数据创建了一个标准的 sparklines 图表。

默认的 sparklines 选项与经典示例略有不同。图 3-2:默认的 sparklines 选项与经典示例略有不同。

该库的默认选项在颜色、图表类型和密度上与 Tufte 的经典 sparkline 略有不同。接下来我们将进行调整。

步骤 4:调整图表样式

为了让我们的火花图完全符合 Tufte 的定义,我们可以为一些默认选项指定新的值。为了将这些选项传递给火花图,我们构造一个 JavaScript 对象,并将其作为第二个参数包含在 sparkline 函数调用中。该函数的第一个参数是数据本身,这里我们使用 "html" 来指定,因为我们的数据已经包含在 HTML 标记中。

   $(".sparkline").sparkline("html",{
➊     lineColor: "dimgray",
➋     fillColor: **false**,
➌     defaultPixelsPerValue: 1,
➍     spotColor: "red",
       minSpotColor: "red",
       maxSpotColor: "red",
➎     normalRangeMin: 82,
       normalRangeMax: 180,
   });

为了完成对 Tufte 原始设计的转变,我们还可以对 HTML 内容进行样式调整。将最终值与关键数据点设置为相同的颜色,可以清晰地表明它们之间的关系,而将图表标签设为粗体可以突出它作为标题。

**<p>**
  **<span** class="sparkline"**>**
    170,134,115,128,168,166,122,81,56,39,97,114,114,130,151,
    184,148,145,134,145,145,145,143,148,224,181,112,111,129,
    151,131,131,131,114,112,112,112,124,187,202,200,203,237,
    263,221,197,184,185,203,290,330,330,226,113,148,169,148,
    78,96,96,96,77,59,22,22,70,110,128
  **</span>**
  **<span** style="color:red"**>** 128 **</span>**
  **<strong>** Glucose **</strong>**
**</p>**

让我们来回顾一下我们刚刚做的更改:

  • Tufte 的经典火花图是黑白色的,只有关键数据点(最小值、最大值和最终值)有颜色。他的配色方案为这些点增加了额外的强调。为了改变库中的默认颜色(蓝色),我们可以设置 lineColor。对于屏幕显示,我们可能选择深灰色而不是纯黑色。这正是我们在 ➊ 处使用的颜色。

  • Tufte 没有填充线下方的区域,而是使用阴影来表示正常范围。为了去除库中的浅蓝色阴影,我们将 fillColor 设置为 false ➋。

  • 默认情况下,库为每个数据点使用 3 像素的宽度。为了最大化信息密度,Tufte 可能会建议使用单个像素。将 defaultPixelsPerValue 选项设置为 ➌ 就可以实现这一变化。

  • Tufte 使用红色来表示关键数据点。为了改变库中的默认颜色(橙色),我们将 spotColorminSpotColormaxSpotColor 设置为 ➍。

  • 最后,Tufte 的火花图可以包括阴影来标记值的正常范围。例如,要显示 82–180 mg/dL 的范围,我们将 normalRangeMinnormalRangeMax 选项设置为 ➎。

通过这些更改,我们已经在网页上展示了经典的 Tufte 火花图。我们甚至可以将其嵌入到文本段落中,就像这样,,这样可视化就能增强文本内容。

绘制多个变量

根据设计,火花图在页面上占用的空间非常少,这使得它们成为另一个可视化挑战的理想选择:一次性展示多个变量。当然,常规的折线图和柱状图可以同时绘制多个数据集;然而,当数据集的数量超过四五个时,这些多系列图表会迅速变得笨重。一些可视化项目展示了几十个不同的变量,远远超出了多系列图表能够容纳的范围。小多重方法完全颠覆了标准图表的方法。我们可以展示多个图表,每个图表只有一个数据集,而不是展示一个包含多个数据集的图表。在页面上放置大量图表意味着每个单独的图表不能占用太多空间,这正是火花图解决的问题。

我们不会在这里做得过于复杂,以保持代码示例的简洁,但这个方法很容易扩展到更多的变量。在我们的例子中,我们将构建一个用于分析股票市场表现的可视化。我们的分析公司包括 2012 年美国最大的 10 家公司(money.cnn.com/magazines/fortune/fortune500/2012/full_list/),也就是《财富》500 强前 10 名;2012 年 Barclay 最佳科技股票(www.marketwatch.com/story/barclays-best-tech-stocks-for-2012-2011-12-20/),该名单于 2011 年 12 月发布;以及 Bristol-Myers Squibb,这家公司被CR Magazine评为美国最佳企业责任公司(www.thecro.com/files/100Best2012_List_3.8.pdf/)。这些选择完全是任意的,但示例的设计旨在包含三种不同的情况,我们将对它们在可视化中进行不同的样式处理。我们将其中一个作为一般案例(《财富》500 强前 10 名名单),一个作为特殊类别(Barclay 名单),一个作为独特变量(Bristol-Myers Squibb)。就像本章第一个例子一样,我们需要在网页中包含 sparklines 和 jQuery 库。

第 1 步:准备 HTML 标记

sparklines 库使得直接在 HTML 标记中嵌入数据变得非常容易。对于这个例子,HTML 表格是最合适的数据结构。以下是该表格可能的起始部分。(为了简洁起见,以下摘录没有包含完整的 HTML 代码,但完整的示例可以在书籍的源代码中找到,地址是jsDataV.is/source/。)

**<table>**
    **<thead>**
        **<tr>**
            **<th>**Symbol**</th>**
            **<th>**Company**</th>**
            **<th>**2012 Performance**</th>**
            **<th>**Gain**</th>**
        **</tr>**
    **</thead>**
    **<tbody>**
        **<tr** class="barclays"**>**
            **<td>**AAPL**</td>**
            **<td>**Apple Inc.**</td>**
            **<td** class="sparkline"**>**
                418.68,416.11,416.6,443.34,455.63,489.08,497.7,517.81,...
            **</td>**
            **<td>**27%**</td>**
        **</tr>**
        **<tr** class="barclays"**>**
            **<td>**ALTR**</td>**
            **<td>**Altera Corporation**</td>**
            **<td** class="sparkline"**>**
                37.1,36.92,39.93,39.81,40.43,39.76,39.73,38.55,36.89,...
            **</td>**
            **<td>**-7%**</td>**
        **</tr>**
        *// Markup continues...*
    **</tbody>**
**</table>**

该表格有三个与我们的可视化相关的重要特征。

  • 每只股票都是一个单独的表格行(<tr>)。

  • 来自 Barclay 技术名单的股票在相应的<tr>元素中增加了类属性"barclays"

  • 顶级企业责任股票没有特殊的属性或特征(暂时没有)。

第 2 步:绘制图表

就像本章第一个例子一样,使用默认选项创建 sparklines 非常简单:只需要一行 JavaScript 代码。我们使用 jQuery 来选择所有包含 sparkline 数据的元素,然后调用sparkline()函数来生成图表。

$(**function**() {
    $(".sparkline").sparkline();
}

请注意,尽管每个图表都有独特的数据,我们只需要调用一次sparkline()。这就是将数据放置在 HTML 内部的一个主要好处。

结果图表,如图 3-3 所示,都具有相同的样式,但我们将在接下来的几个步骤中进行修改。

火花图可以作为一个不错的可视化效果,嵌入在页面元素中,例如表格。图 3-3. 火花图可以作为一个不错的可视化效果,嵌入在页面元素中,例如表格。

步骤 3:为图表建立默认样式

如果我们不喜欢火花图库的默认样式,可以通过使用选项对象轻松进行更改,如下所示。

$(".sparkline").sparkline("html",{
    lineColor: "#006363",
    fillColor: "#2D9999",
    spotColor: **false**,
    minSpotColor: **false**,
    maxSpotColor: **false**
});

对象是 sparkline() 函数的第二个参数,它在这里用于更改图表的颜色,并禁用最小值、最大值和最终值的高亮显示。第一个参数,即字符串 "html",告诉库数据已经存在于我们的 HTML 中。

图 3-4 显示了单行的结果。我们将使用这个样式作为所有图表的默认样式。

火花图选项让我们调整图表样式。图 3-4. 火花图选项让我们调整图表样式。

步骤 4:修改特殊类的默认样式

在设置了默认样式之后,我们可以把注意力转向 Barclay 技术列表中的股票图表特殊类。以我们的示例为例,让我们仅更改图表的颜色,而不修改默认样式的其他内容。这个最后的声明非常重要。我们本可以直接复制粘贴选项,但那样会为未来带来问题。你可以从以下示例代码中看到为什么。

$("tr:not(.barclays) .sparkline").sparkline("html",{
    lineColor: "#006363",
    fillColor: "#2D9999",
    spotColor: **false**,
    minSpotColor: **false**,
    maxSpotColor: **false**
});
$("tr.barclays .sparkline").sparkline("html",{
    lineColor: "#A50000",
    fillColor: "#FE4C4C",
    spotColor: **false**,
    minSpotColor: **false**,
    maxSpotColor: **false**
});

请注意,第二次调用 sparklines() 会复制第一次调用中未更改的选项,特别是关于点颜色的设置。如果将来我们决定为所有图表重新启用点颜色,这会使代码更加难以维护,因为我们必须在两个地方修改代码。其实有一种更好的方法。

为了避免重复,我们首先定义一个变量来保存我们的默认选项。

**var** sparkline_default = {
    lineColor: "#006363",
    fillColor: "#2D9999",
    spotColor: **false**,
    minSpotColor: **false**,
    maxSpotColor: **false**
};

接下来,我们为 Barclay 的样式创建一个新变量。为了创建这个新变量,我们可以使用 jQuery 的 .extend() 函数来避免重复。

**var** sparkline_barclays = $.extend( {}, sparkline_default, {
    lineColor: "#A50000",
    fillColor: "#FE4C4C"
});

在这段代码中,我们传递了三个参数给 .extend()。第一个参数是目标对象。它是一个将被函数修改的对象,我们从一个空对象({})开始。接下来的参数是将被 .extend() 合并到目标对象中的对象。合并过程会将新属性添加到目标对象,并更新目标对象中已有属性的值。由于我们传递了两个附加参数,实际上是要求进行两次合并。

你可以将调用 .extend() 看作是一个两阶段的过程。

  1. 由于我们的目标对象最初是空的,因此第一次合并会将 sparkline_default 中的所有属性添加到目标对象。

  2. 我们的目标对象现在具有与 sparkline_default 相同的属性,第二次合并将通过更新最后一个参数中的两个属性 lineColorfillColor 来修改它。

生成的对象将包含我们为巴克莱(Barclay)技术股票图表所需的选项。以下是完整的代码清单,使用这些对象来创建图表。

   **var** sparkline_default = {
       lineColor: "#006363",
       fillColor: "#2D9999",
       spotColor: **false**,
       minSpotColor: **false**,
       maxSpotColor: **false**
   };
   **var** sparkline_barclays = $.extend( {}, sparkline_default, {
       lineColor: "#A50000",
       fillColor: "#FE4C4C"
   });
➊ $("tr:not(.barclays) .sparkline").sparkline("html",sparkline_default);
➋ $("tr.barclays .sparkline").sparkline("html",sparkline_barclays);

请注意在 ➊ 处,我们通过选择没有 "barclays" 类的表格行(<tr>)来创建非技术类的 sparklines。在 ➋ 处,我们创建技术类的 sparklines。由于我们根据默认值定义了技术选项,因此我们可以轻松维护默认样式和特定类的样式。图 3-5 中的图表颜色清晰地区分了表格中的股票类型。

不同的视觉样式区分不同类型的数据。图 3-5. 不同的视觉样式区分不同类型的数据。

第 5 步:为特定图表创建独特样式

对于本例中的最后一步,让我们考虑 CR Magazine 列表顶部的单个股票。假设我们想为其图表添加独特的样式,而且只有在生成 HTML 时我们才能知道这些样式,而不是在编写 JavaScript 时。我们该如何调整图表样式,如果我们无法修改任何 JavaScript?

Sparklines 允许你直接向包含图表的 HTML 元素添加特殊属性。例如,要设置线条颜色,你需要指定 sparkLineColor 属性。问题是,如果我们直接在 HTML 中输入此属性,结果将不是有效的 HTML,因为 HTML 规范不识别 sparkLineColor 属性。为了符合 HTML 标准,自定义属性必须以 data- 前缀开头。

   **<tr>**
       **<td>**BMY**</td>**
       **<td>**Bristol Meyers Squibb Co.**</td>**
➊     **<td** class="sparkline" data-LineColor="#679A00"
           data-FillColor="#B6ED47"**>**32.86,32.46,31.36,...**</td>**
       **<td>**(2%)**</td>**
   **</tr>**

要使用符合 HTML 标准的名称来引用 sparklines 的自定义属性,我们只需要告诉 sparklines 库如何找到这些名称。对于我们的 HTML,我们在 ➊ 处使用标准的 data- 前缀,而不是 spark

现在,我们需要在调用 sparkline() 时添加更多选项。首先,我们将 enableTagOptions 设置为 true,告诉库我们将在 HTML 中直接包含选项。然后,我们将 tagOptionsPrefix 设置为 "data-",指定我们用于这些属性的前缀。

注释

截至本文写作时,jQuery sparklines 关于 tagOptionsPrefix 的文档是不正确的。文档将此选项列为 tagOptionPrefix,其中 option 是单数形式,而库的代码实际上期望的是复数形式。

如果我们正确使用这些选项,其中一个图表将在 图 3-6 中显示出不同的颜色。

sparklines 库支持单个图表的独特样式选项。图 3-6. sparklines 库支持单个图表的独特样式选项。

为了将适当的选项传递给sparkline(),我们可以利用在步骤 5 中所做的工作。由于我们为默认选项创建了一个特殊对象,这就是我们唯一需要更改的对象。

**var** sparkline_default = {
    lineColor: "#006363",
    fillColor: "#2D9999",
    spotColor: **false**,
    minSpotColor: **false**,
    maxSpotColor: **false**,
    enableTagOptions: **true**,
    tagOptionsPrefix: "data-"
};

我们只需在一个地方进行更改,所有调用sparkline()的地方都会使用新的选项。以下是该示例的最终完整 JavaScript 代码。

$(**function**() {
    **var** sparkline_default = {
        lineColor: "#006363",
        fillColor: "#2D9999",
        spotColor: **false**,
        minSpotColor: **false**,
        maxSpotColor: **false**,
        enableTagOptions: **true**,
        tagOptionsPrefix: "data-"
    };
    **var** sparkline_barclays = $.extend( {}, sparkline_default, {
        lineColor: "#A50000",
        fillColor: "#FE4C4C"
    });
    $("tr:not(.barclays) .sparkline").sparkline("html",sparkline_default);
    $("tr.barclays .sparkline").sparkline("html",sparkline_barclays);
}

图 3-7 展示了最终结果。我们有一个集成文本和图表的表格,并且我们可以为默认情况、特定类以及唯一值适当地和高效地样式化这些图表。

完整示例区分了大型集合中的不同数据集。图 3-7. 完整示例区分了大型集合中的不同数据集。

追踪数据值使用了一个功能完整的图表包来实现类似的结果。如果你不需要 sparklines 的空间效率,可以考虑这种方法作为替代方案。

注释 Sparklines

由于 sparklines 旨在最大化信息密度,因此省略了许多传统图表组件,例如坐标轴和标签。这种方法确实将重点放在数据本身,但有时会让用户缺乏足够的上下文来理解数据。打印版通常依赖传统文本来提供这些上下文,但在网页上我们有更多的灵活性。我们可以单独通过 sparkline 展示数据,并且可以通过交互让用户有机会探索数据的上下文。工具提示(当用户将鼠标悬停在网页的某个部分时显示额外信息)可以是注释 sparkline 的有效方法,前提是用户是通过桌面电脑访问页面。(如智能手机和平板电脑等触摸设备通常不支持悬停概念。)我们将在这个示例中演示包含工具提示的可视化;本章中的其他示例则考虑了触摸设备可能更有效的替代方法。让我们看看如何通过增强前一个示例中的图表来使用定制的工具提示形式。就像本章第一个示例中一样,我们需要在网页中包含 sparklines 和 jQuery 库。

步骤 1:准备数据

在之前的示例中,我们将数据直接嵌入 HTML 标记中。这种做法很方便,因为它让我们可以将数据与代码分离。然而,在这个示例中,JavaScript 代码需要更详细的数据知识,以便能够展示正确的工具提示信息。这次我们将使用 JavaScript 数组来存储数据,以便将所有相关信息集中在一个地方。对于这个示例,我们可以专注于单一的股票。即使我们只绘制了调整后的收盘价,数组仍然会跟踪其他数据,以便我们可以在工具提示中包含这些额外信息。以下是某只股票数据的一个摘录。

**var** stock = 
  { date: "2012-01-03", open: 409.40, high: 422.75, low: 409.00, close: 422.40,
    volume: 10283900, adj_close: 416.26 },
  { date: "2012-01-09", open: 425.50, high: 427.75, low: 418.66, close: 419.81,
    volume: 9327900, adj_close: 413.70 },
  { date: "2012-01-17", open: 424.20, high: 431.37, low: 419.75, close: 420.30,
    volume: 10673200, adj_close: 414.19 },
  *// Data set continues...*

第 2 步:准备 HTML 标记

我们的可视化将包括三个不同的区域,每个区域都放在一个<div>元素中。

   **<div** id="stock"**>**
       **<div** style="float:left"**>**
➊         **<div** class="chart"**></div>**
➋         **<div** class="info"**></div>**
       **</div>**
       **<div** style="float:left"**>**
➌         **<div** class="details"**></div>**
       **</div>**
   **</div>**

在➊处创建的主要<div>元素将容纳图表。图表下方我们将添加包含主要工具提示信息的<div>元素➋,并在右侧添加补充详细信息➌。这个示例使用内联样式以便清晰展示;生产环境中可能更倾向于使用 CSS 样式表。

第 3 步:添加图表

使用 sparklines 库将图表添加到我们的标记中非常简单。我们可以使用 jQuery 的.map()函数从我们的stock数组中提取调整后的收盘值。minSpotColormaxSpotColor选项告诉库如何突出显示年度中的最低值和最高值。

$("#stock .chart").sparkline(
    $.map(stock, **function**(wk) { **return** wk.adj_close; }),
    {
        lineColor: "#006363",
        fillColor: "#2D9999",
        spotColor: **false**,
        minSpotColor: "#CA0000",
        maxSpotColor: "#CA0000"
    }
);

[图 3-8 中的静态图表清晰地展示了股票的表现。

静态 sparkline 展示了数据集随时间变化的变化。图 3-8. 静态 sparkline 展示了数据集随时间变化的变化。

第 4 步:添加主要注释

sparklines 库默认会为其所有图表添加一个简单的工具提示。虽然这个工具提示会显示用户鼠标悬停的值,但其展示方式并不特别优雅,而且更重要的是,它没有提供我们所需的足够信息。我们来增强默认行为以满足我们的需求。

查看库的默认设置,我们可以保留垂直标记,但不希望使用默认的工具提示。通过将选项disableTooltips设置为true,可以关闭不需要的工具提示。

对于我们自己的自定义工具提示,我们可以依赖 sparklines 库的一个便捷功能。该库会在用户的鼠标移动到图表区域时生成一个自定义事件。这个事件就是spaklineRegionChange事件。库会将一个自定义属性sparklines附加到这些事件中。通过分析这个属性,我们可以确定鼠标相对于数据的位置。

   $(".chart")
       .on("sparklineRegionChange", **function**(ev) {
           **var** idx = ev.sparklines[0].getCurrentRegionFields().offset;
➊         */* If it's defined, idx has the index into the*
              *data array corresponding to the mouse pointer */*
       });

正如➊处的注释所示,库有时会在鼠标离开图表区域时生成事件。在这些情况下,偏移量的定义值将不存在。

一旦我们获取到鼠标位置,就可以将工具提示信息放入我们为此预留的<div>元素中。

   **if** (idx) {
       $(".info").html(
➊         "Week of " + stock[idx].date
         + "&nbsp;&nbsp;&nbsp; "
➋       + "Close: $" + stock[idx].adj_close);
   }

我们通过stock数组中的索引值获取➊和➋的信息,这些索引值来自于sparklineRegionChange事件。

由于火花图库在生成鼠标离开图表区域的事件时并不完全可靠,因此我们可以使用标准的 JavaScript mouseout事件,而不是自定义事件。当用户将鼠标移出图表时,我们会通过将内容设置为空格来关闭自定义工具提示。我们使用 HTML 的非断行空格(&nbsp;),这样浏览器不会认为<div>完全为空。如果我们使用标准的空格字符,浏览器会将<div>视为空并重新计算页面高度,导致页面内容出现烦人的跳动。(出于同样的原因,我们应初始化<div>时使用&nbsp;,而不是留空。)

.on("mouseout", **function**() {
    $(".info").html("&nbsp;");
});

为了实现最简洁的方式,我们使用方法链将所有这些步骤结合起来。(为了简洁起见,下面的代码省略了图表样式设置选项。)

$("#stock .chart")
    .sparkline(
        $.map(stock, **function**(wk) { **return** wk.adj_close; }),
        { disableTooltips: **true** }
    ).on("sparklineRegionChange", **function**(ev) {
        **var** idx = ev.sparklines[0].getCurrentRegionFields().offset;
        **if** (idx) {
            $(".info").html(
                "Week of " + stock[idx].date
              + "&nbsp;&nbsp;&nbsp; "
              + "Close: $" + stock[idx].adj_close);
        }
    }).on("mouseout", **function**() {
        $(".info").html("&nbsp;");
    });

现在通过图 3-9,我们拥有了一个很好的交互式工具提示,它会随着用户的鼠标在图表上移动并提供相关的信息。

交互式火花图跟踪用户的鼠标并提供与鼠标位置相关的信息。图 3-9. 交互式火花图跟踪用户的鼠标并提供与鼠标位置相关的信息。

第 5 步:提供附加信息

到目前为止,我们添加的工具提示信息展示了与用户最相关的信息:本周和调整后的股票收盘价。然而,我们的数据还包含可能对用户有用的附加信息。我们可以通过在原始工具提示的基础上展示这些内容来进行扩展。

同时在更新主工具提示区域时,我们也添加额外的数据。

$(".details").html(
    "Open: $" + stock[idx].open + "<br/>"
  + "High: $" + stock[idx].high + "<br/>"
  + "Low: $" + stock[idx].low + "<br/>"
  + "Volume: " + stock[idx].volume
);

当我们清除主工具提示区域时,也会清除这个区域。

$(".details").html("");

因为这不会影响页面的垂直大小,所以我们不需要用虚拟的&nbsp;填充这个<div>

通过图 3-10,我们得到了想要的可视化效果。图表清晰地显示了股票在一年的总体趋势,但它只占用了网页上很小的空间。乍一看,图表也没有干扰的元素,比如标签和坐标轴。对于那些只想了解股票大致表现的用户,这些元素是多余的。想要获取完整细节的用户只需将鼠标悬停在图表上,完整的市场信息就会显现出来。

交互式火花图可以以多种方式显示附加信息。图 3-10. 交互式火花图可以以多种方式显示附加信息。

由于我们成功地在保留 sparklines 简洁特性的同时展示了信息,本示例中的技术与本章第二个示例的小型多重方法结合使用时效果很好。下一个示例包括显示额外细节的替代方法。

绘制复合图表

到目前为止,在本章中,我们已经看到 sparklines 如何在非常小的空间内提供大量视觉信息。正是这一特性使得 sparklines 非常适合在包含文本、表格和其他元素的完整网页中集成图表。然而,我们还没有完全发挥 sparklines 的功能。通过创建复合图表,我们可以进一步增加可视化的数据信息密度——实际上,就是在同一空间中绘制多个图表。

为了看到这一技术的示例,我们可以在之前的示例上进行扩展。在那个示例中,我们使用了 sparkline 来显示股票的全年收盘价格。价格确实是股票最相关的数据,但还有另一个许多投资者喜欢查看的量:股票的交易量。就像价格一样,了解交易量的趋势一眼就能看出,也非常重要。这使得它成为图表的优秀候选值。

就像在本章的第一个示例中一样,我们需要在网页中包含 sparklines 和 jQuery 库。因为我们在可视化与前一个示例相同的数据,所以我们也需要设置与该示例完全相同的数据数组和 HTML 标记。

步骤 1:绘制交易量图表

尽管我们包含了交易量的图表,但最重要的量是股票价格。为了突出股票价格,我们希望将其图表叠加在交易量图表之上。这意味着我们需要先绘制交易量图表。

交易量的代码与前一个示例中的股票价格非常相似。然而,我们将使用柱状图,而不是面积图。

   $("#stock .chart").sparkline(
       $.map(stock, **function**(wk) { **return** wk.volume; }),
➊     { type: "bar" }
   );

我们使用 jQuery 的.map()函数从数据数组中提取volume属性。在➊位置将type选项设置为"bar",即可告诉 sparklines 库创建柱状图。

图 3-11 展示了结果。

sparklines 库可以创建柱状图和折线图。图 3-11。sparklines 库可以创建柱状图和折线图。

步骤 2:添加收盘价格图表

为了将价格图表叠加在交易量图表之上,我们可以再次调用 sparklines 库。

   $("#stock .chart")
       .sparkline(
           $.map(stock, **function**(wk) { **return** wk.volume; }),
       {
           type: "bar"
       }
   ).sparkline(
       $.map(stock, **function**(wk) { **return** wk.adj_close; }),
       {
➊         composite: **true**,
           lineColor: "#006363",
           fillColor: "rgba(45, 153, 153, 0.3)",
           disableTooltips: **true**
       }
   );

我们为其指定相同的容器元素,最重要的是,在➊位置将composite选项设置为true。此参数告诉库不要删除元素中任何现有的图表,而是直接在其上绘制。

请注意我们为第二个图表指定填充颜色的方式。我们设置了一个透明度(或alpha)值为0.3。这个值使得图表区域几乎透明,因此交易量图表会透过显示。然而需要注意的是,一些较老的网页浏览器,特别是 IE8 及更早版本,无法支持透明度标准。如果你的站点有大量使用这些浏览器的用户,您可以考虑将fillColor选项设置为false,这样就会完全禁用区域填充。

如图 3-12 所示,结果将两个图表合并在同一空间内。

多个图表可以合并在同一空间内。图 3-12. 多个图表可以合并在同一空间内。

步骤 3:添加注释

我们可以使用与前面示例相同的方法为图表添加注释。因为我们的图表现在包含了交易量,所以将该值从细节区域移动到主要注释<div>中是合适的。实现这一点的代码只是对前一个示例的简单调整。

   .on("sparklineRegionChange", **function**(ev) {
➊     **var** idx = ev.sparklines[1].getCurrentRegionFields().offset;
       **if** (idx) {
           $(".info").html(
             "Week of " + stock[idx].date
           + "&nbsp;&nbsp;&nbsp; Close: $" + stock[idx].adj_close
➋         + "&nbsp;&nbsp;&nbsp; Volume: "
           + Math.round(stock[idx].volume/10000)/100 + "M"
           );
           $(".details").html(
               "Open: $" + stock[idx].open + "<br/>"
             + "High: $" + stock[idx].high + "<br/>"
             + "Low: $" + stock[idx].low
           );
       }

除了将文本从一个区域移动到另一个区域,我们还做了两个重要的更改。

  • 我们从事件的sparklines数组中的第二个元素(sparklines[1])获取idx值,见 ➊。这是因为该数组的第一个元素是第一个图表。在spaklineRegionChange事件中,sparklines 库并没有返回关于柱状图的任何有用信息。幸运的是,我们可以从线图中获得所需的所有信息。

  • 我们将交易量以百万为单位显示,并四舍五入到小数点后两位。计算过程在 ➋ 处。用户更容易理解“24.4M”而不是“24402100”。

如同前面的例子,我们图表中的注释(见图 3-13)提供了额外的细节。

鼠标位置跟踪使得可以互动地注释图表。图 3-13. 鼠标位置跟踪使得可以互动地注释图表。

步骤 4:将细节显示为图表

到目前为止,我们已经将股票的额外细节(开盘、收盘、最高和最低价)作为文本值显示。只要我们绘制多个图表,也可以将这些值以图形的形式展示。统计框图是一个有用的模型。传统上,这种图表类型显示分布的范围,包括偏差、四分位数和中位数。然而,从视觉上看,它提供了一个完美的模型来展示股票的交易表现。我们可以用它来展示开盘和收盘价格,以及期间的最高和最低值。

sparklines 库可以为我们绘制箱形图,但通常它根据分布数据计算要显示的值。在我们的案例中,我们不想使用标准的统计计算方法。相反,我们可以使用一个选项,告诉库使用预计算的值。该库至少需要五个值:

  • 最低样本值

  • 第一个四分位数

  • 中位数

  • 第三个四分位数

  • 最高样本值

对于我们的示例,我们将提供以下值:

  • 最低价格

  • 开盘价和收盘价中较小的一个

  • 调整后的收盘价

  • 开盘价和收盘价中较大的一个

  • 最高价格

我们还将根据股票在此期间是上涨还是下跌,将中位线条颜色标记为红色或绿色。

这段代码在响应 sparklineRegionChange 事件时生成该图表:

   $("#composite-chart4 .details")
       .sparkline([
➊         stock[idx].low,
           Math.min(stock[idx].open,stock[idx].close),
           stock[idx].adj_close,
           Math.max(stock[idx].open,stock[idx].close),
           stock[idx].high
       ], {
           type: "box",
           showOutliers: **false**,
➋         medianColor: (stock[idx].open < stock[idx].close)
➌          ? "green" : "red"
       });

图表中的数据(如➊所示)只是从股票数据中提取出的适当一周的五个值。正如➋和➌所示,我们可以根据股票当天是上涨还是下跌来改变中位线条的颜色。

当鼠标离开图表区域时,我们可以通过清空其容器来移除箱形图。

$(".details").empty();

当用户将鼠标悬停在图表区域时,他们可以看到每个时间段内股票价格区间的可视化表示(图 3-14)。

交互式注释不仅可以是文本,还可以是图表。图 3-14. 交互式注释不仅可以是文本,还可以是图表。

响应点击事件

在本章中,我们探讨了如何在有限的空间内包含大量的视觉信息,使得将可视化集成到网页中变得更加容易。基本的 sparklines 本身非常高效,前面的示例中已经通过添加注释和复合图形进一步提高了信息密度。然而,有时候就是没有办法在足够小的空间内放下所有可能的数据。不过,即使在这些情况下,网页的互动特性仍然能够帮助我们。我们的网页可以从一个紧凑的可视化开始,但通过简单的点击或触摸,切换到一个不同的视图——一个包含更多细节的视图。

事实上,sparklines 的紧凑性似乎非常适合交互。在我进行的每一个包含 sparklines 的网页可用性测试中,参与者总是点击了图表。即使页面没有提供任何其他细节,参与者也不知道点击后会有什么反应,他们还是点击了,只是想看看会发生什么。

这个示例继续我们的股票市场示例。我们将从之前看到的基本股票价格图表开始,但对其进行增强,以便用户点击图表区域时能够显示更多细节。

就像本章的第一个示例一样,我们需要在网页中包含 sparklines 和 jQuery 库。由于我们正在可视化与前一个示例相同的数据,因此我们还需要按照该示例的方式设置数据数组。然而,HTML 标记可以简单得多。我们只需要一个<div>来容纳图表。

**<div** id="stock"**></div>**

第 1 步:添加图表

使用 sparklines 库将图表添加到我们的标记中非常简单。我们可以使用 jQuery 的.map()函数从我们的stock数组中提取调整后的收盘值。

$("#stock").sparkline($.map(stock, **function**(wk) { **return** wk.adj_close; }));

图 3-15 展示的静态图表,显示了股票表现,现在可能已经看起来很熟悉了。

从静态图表开始确保可视化的准确性。图 3-15。从静态图表开始确保可视化的准确性。

第 2 步:处理点击事件

sparklines 库使我们可以轻松处理点击事件。当用户点击图表区域时,库会生成一个自定义的sparklineClick事件。事件数据包括所有正常的点击属性,以及用户点击图表的位置的相关信息。为了接收点击通知,我们需要为该自定义事件定义一个处理程序。

$("#stock")
    .sparkline($.map(stock, **function**(wk) { **return** wk.adj_close; }))
    .on("sparklineClick", **function**(ev) {
        **var** sparkline = ev.sparklines[0],
        region = sparkline.getCurrentRegionFields();
        */* region.x and region.y are the coordinates of the click */*
    });

现在我们已经设置好接收sparklineClick事件,可以编写代码来响应这些事件。对于我们的示例,我们将显示一个详细的财务分析小部件。许多网页服务,包括 Yahoo 和 Google,都有类似的小部件,但我们将使用来自 WolframAlpha 的小部件。像往常一样,WolframAlpha 提供该小部件的 HTML<iframe>代码。我们可以将该<iframe>包裹在我们自己的<div>中,并将其放置在图表后面。我们将display属性设置为none,使其内容最初处于隐藏状态。(以下代码片段为了清晰起见省略了<iframe>元素的详细内容。)

**<div** id="stock"**></div>**
**<div** id="widget" style="display:none"**><iframe></iframe></div>**

现在我们的事件处理代码可以使用 jQuery 的show()函数来显示小部件。

.on("sparklineClick", **function**(ev) {
  $("#widget").show();
});

这样可以显示更多细节,但正如图 3-16 所示,结果的呈现方式并不如预期那样优雅,因为细节显示得过于突兀。

鼠标点击可以显示图表的更多细节。图 3-16。鼠标点击可以显示图表的更多细节。

第 3 步:改善过渡效果

与其简单地显示图表下方的小部件,不如让小部件替代图表。如果我们这样做,我们还希望给用户一个机会来恢复图表并隐藏小部件。

   **<div** id="stock"**></div>**
➊ **<div** id="widget-control" style="width:600px;display:none"**>**
       **<a** href="#" style="float:right"**>**&times;**</a>**
   **</div>**
   **<div** id="widget" style="width:600px;display:none"**>**
       **<iframe></iframe>**
   **</div>**

在这里,我们包括了一个 "widget-control" <div> ➊,用于控制小部件的可见性。这个控制器唯一需要的内容是一个浮动到右边的关闭符号。和小部件本身一样,控制器最初也是隐藏的。

现在,当用户点击图表时,我们会显示小部件,展示控制器,并隐藏图表。

.on("sparklineClick", **function**(ev) {
    $("#widget").show();
    $("#widget-control").show();
    $("#stock").hide();
});

接下来,我们拦截小部件控制器中关闭符号的点击事件。首先,我们阻止默认的事件处理;否则,浏览器会令人不安地跳到页面顶部。然后,我们隐藏小部件及其控制器,同时再次显示图表。

$("#widget-control a").click(**function**(ev) {
    ev.preventDefault();
    $("#widget").hide();
    $("#widget-control").hide();
    $("#stock").show();
})

最后,我们需要给用户一些提示,告诉他们这个交互是可能的。

   $("#stock")
       .sparkline(
           $.map(stock, **function**(wk) { **return** wk.adj_close; }),
➊         { tooltipFormatter: **function**() {**return** "Click for details"; } }
       );

在图表上,我们重写了 sparklines 库的默认工具提示➊,让用户知道有更多的细节可供查看。

现在来看看小部件控制器:

   **<div** id="stock"**></div>**
   **<div** id="widget-control" style="width:600px;display:none"**>**
➊     **<a** href="#" title="Click to hide" style="float:right;"**>**&times;**</a>**
   **</div>**
   **<div** id="widget" style="width:600px;display:none"**>**
       **<iframe></iframe>**
   **</div>**

在这里,我们仅在➊添加了一个title属性,告诉用户如何隐藏小部件。

这些新增功能为我们提供了简单的 sparkline 图表,见图 3-17,它通过单击即可展开,提供丰富的细节。右上角的关闭符号让用户可以返回到更简洁的 sparkline。

鼠标点击可以揭示图表的更多细节。图 3-17. 鼠标点击可以揭示图表的更多细节。

第 4 步:动画

为了给我们的可视化效果做最后的修饰,我们来处理一下可视化组件的突然隐藏和显示。更平滑的动画效果将帮助用户更好地跟随过渡,jQuery 使得实现这一点变得非常容易。jQuery UI 库中有许多动画效果,但 jQuery 核心的基本功能就足够用于本例。我们只需将show()hide()函数分别替换为slideDown()slideUp()

.on("sparklineClick", **function**(ev) {
    $("#widget").slideDown();
    $("#widget-control").slideDown();
    $("#stock").slideUp();
});
$("#widget-control a").click(**function**(ev) {
    ev.preventDefault();
    $("#widget").slideUp();
    $("#widget-control").slideUp();
    $("#stock").slideDown();
})

到此为止,我们可以宣布我们的可视化完成;最终产品如图 3-18 所示。当用户点击时,紧凑的 sparkline 平滑过渡为详细信息,当用户关闭它们时,这些细节又过渡回 sparkline。

动画过渡可以使可视化效果对用户更为友好。图 3-18. 动画过渡可以使可视化效果对用户更为友好。

实时更新图表

正如我们在本章的其他示例中所看到的,sparklines 非常适合将可视化嵌入到完整的网页中。它们可以嵌入到文本内容中,或作为表格元素使用。另一个适合使用 sparklines 的应用是信息仪表盘。有效的仪表盘能一目了然地总结底层系统的健康状况。当用户没有时间阅读长篇文字或详细图形时,sparklines 的高信息密度使它们成为理想的工具。

除了高信息密度外,大多数仪表板还有另一个要求:它们必须是最新的。对于基于 Web 的仪表板,这意味着内容应持续更新,即使用户正在查看页面。没有理由要求用户刷新浏览器。幸运的是,sparklines 库使得满足这个要求变得容易。

就像本章第一个示例中一样,我们需要在网页中包含 sparklines 和 jQuery 库。对于这个可视化,我们将同时显示图表和数据的最新值。我们为每个定义了<div>元素,并将两者放在一个包含的<div>中。以下代码包含了一些内联样式,但你也可以将它们放在外部样式表中。这里的样式只是为了将数值直接放在图表的右侧,而不是单独一行。

**<div** id="dashboard"**>**
    **<div** id="chart" style="float:left"**></div>**
    **<div** id="value" style="float:left"**></div>**
**</div>**

第一步:获取数据

在一个真实的仪表板示例中,服务器将提供要显示的数据以及对该数据的更新。只要更新的频率适中(大约每五秒一次),我们可以定期轮询服务器获取更新。然而,使用 JavaScript 的setInterval()函数来控制轮询间隔可能不是个好主意。虽然setInterval()函数会定期执行某个函数,看起来能够完全满足需求,但情况并没有那么简单。如果服务器或网络遇到问题,则setInterval()触发的请求将持续不断地堆积在队列中。当与服务器的通信恢复时,所有待处理的请求将立即完成,导致大量数据同时处理。

为了避免这个问题,我们可以改用setTimeout()函数。该函数只会执行一次,因此我们必须显式地多次调用它。不过,通过这种方式,我们可以确保只有在当前请求完成后才会向服务器发送请求。这种方法避免了请求堆积在队列中。

   (**function** getData(){
       setTimeout(**function**(){
           *// Request the data from the server*
           $.ajax({ url: "/api/data", success: **function**(data) {

               *// Data has the response from the server*

               *// Now prepare to ask for updated data*
➊             getData();
           }, dataType: "json"});
       }, 30000); *// 30000: wait 30 seconds to make the request*
➋ })();

请注意,代码的结构定义了getData()函数,并立即执行它。➋处的闭括号触发了即时执行。

success回调中,我们在➊处设置了getData()的递归调用,以便每当服务器响应数据时,函数都会再次执行。

第二步:更新可视化

每当我们从服务器收到更新的信息时,只需更新图表和数值即可。

   (**function** getData(){
       setTimeout(**function**(){
           *// Request the data from the server*
           $.ajax({ url: "/api/data", success: **function**(data) {

➊             $("#chart").sparkline(data);
➋             $("#value").text(data.slice(-1));
               getData();
           }, dataType: "json"});
       }, 30000); *// 30000: wait 30 seconds to make the request*
   })();

该代码只需要简单调用 sparklines 库和一个 jQuery 函数来更新值。我们在➊和➋处已将其添加到代码中。

图 3-19 展示了默认图表的样子。当然,你可以根据自己仪表板的需要指定图表和文本的样式。

实时更新的图表可以显示实时数据。图 3-19. 实时更新的图表可以显示实时数据。

总结

在本章中,我们考虑了将可视化集成到网页中的各种技术。我们看到火花图是一种非常优秀的工具。由于它们能够在小空间内提供大量的视觉信息,因此它们为页面的其他元素(包括文本块、表格和仪表板)留下了空间。我们还考虑了通过注释、复合图表和点击事件等方式进一步增加信息密度。最后,我们了解了如何创建实时更新的图表,准确地可视化底层系统的最新状态。

第四章:创建专门的图表

前三章介绍了使用 JavaScript 创建许多常见图表类型的不同方法。但如果你的数据具有独特的属性,或者你希望以一种不寻常的方式展示它,可能比典型的条形图、折线图或散点图更适合使用更专门的图表。

幸运的是,有许多 JavaScript 技术和插件可以扩展我们的可视化词汇,超越标准图表。在本章中,我们将探讨几种专门图表类型的方法,包括以下内容:

  • 如何将层次结构和维度结合在一起,使用树形图

  • 如何使用热力图突出显示区域

  • 如何使用网络图显示元素之间的连接

  • 如何通过词云揭示语言模式

使用树形图可视化层次结构

我们想要可视化的数据通常可以组织成层次结构,在许多情况下,这种层次结构本身就是可视化的重要组成部分。本章讨论了几种用于可视化层次数据的工具,我们将从最简单的方法之一——树形图开始示例。树形图通过二维区域表示数值数据,并通过将从属区域嵌套在父区域内来表示层次结构。

有几种算法可以从层次数据构建树形图;其中最常见的一种是由 Mark Bruls、Kees Huizing 和 Jarke J. van Wijk 开发的方形算法www.win.tue.nl/~vanwijk/stm.pdf)。由于它通常能为树形图区域生成视觉上令人愉悦的比例,因此这种算法在许多可视化中广受欢迎。为了创建我们示例中的图形,我们可以使用 Imran Ghory 的 treemap-squared 库(github.com/imranghory/treemap-squared)。该库包含了用于计算和绘制树形图的代码。

步骤 1:包含所需的库

treemap-squared 库本身依赖于 Raphaël 库(*raphaeljs.com/)提供低级绘图功能。因此,我们的标记必须同时包含这两个库。Raphaël 库足够流行,公共 CDN 已提供支持。

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
     **</head>**
     **<body>**
       **<div** id="treemap"**></div>**
➊     **<script** src="//cdnjs.cloudflare.com/ajax/libs/raphael/2.1.0/raphael-min.js"**>**
       **</script>**
➋     **<script** src="js/treemap-squared-0.5.min.js"**></script>**
   **</body>**
    **</html>**

如你所见,我们已经为树形图预留了一个<div>元素。我们还将 JavaScript 库作为<body>元素的最后部分包含在内,因为这样能够提供最佳的浏览器性能。在这个示例中,我们依赖 CloudFlare 的 CDN ➊。然而,我们必须使用自己的资源来托管 treemap-squared 库 ➋。

注意

请参见步骤 1:包含所需的 JavaScript 库,以便更详细地讨论 CDN 及其使用中的权衡。

步骤 2:准备数据

在我们的示例中,我们将显示按区域划分的美国人口,然后在每个区域内按州进行细分。数据来自美国人口普查局(www.census.gov/popest/data/state/totals/2012/index.html)。我们将按照其惯例,将该国划分为四个区域。生成的 JavaScript 数组可能如下所示。

census = [
  { region: "South", state: "AL", pop2010: 4784762, pop2012: 4822023 },
  { region: "West",  state: "AK", pop2010:  714046, pop2012:  731449 },
  { region: "West",  state: "AZ", pop2010: 6410810, pop2012: 6553255 },
  *// Data set continues...*

我们保留了 2010 年和 2012 年的数据。

为了将数据结构化为 treemap-squared 库使用,我们需要为每个区域创建单独的数据数组。同时,我们还可以创建数组,用两位州缩写作为标签来标注数据值。

**var** south = {};
south.data = [];
south.labels = [];
**for** (**var** i=0; i<census.length; i++) {
    **if** (census[i].region === "South") {
        south.data.push(census[i].pop2012);
        south.labels.push(census[i].state);
    }
}

这段代码逐步遍历census数组,构建“南部”区域的数据和标签数组。相同的方法也适用于其他三个区域。

步骤 3:绘制树状图

现在我们已经准备好使用该库来构建树状图。我们需要组装各个数据和标签数组,然后调用库的主函数。

   **var** data = [ west.data, midwest.data, northeast.data, south.data ];
   **var** labels = [ west.labels, midwest.labels, northeast.labels, south.labels ];
➊ Treemap.draw("treemap", 600, 450, data, labels);

➊处的前两个参数是地图的宽度和高度。

结果图表,如图 4-1 所示,提供了美国人口的简单可视化。在四个区域中,最明显的就是人口集中在哪个地方。右下方的区域(南部)拥有最多的人口份额。在各个区域内,每个州的人口相对大小也非常清晰。例如,注意到加利福尼亚在西部占据主导地位。

树状图显示数据值的相对大小,使用矩形区域。图 4-1. 树状图显示数据值的相对大小,使用矩形区域。

步骤 4:变换阴影以显示额外数据

图 4-1 中的树状图很好地展示了 2012 年美国人口的分布。然而,人口并非静态的,我们可以通过利用仍然存在于数据集中的 2010 年人口数据来增强我们的可视化效果,显示趋势。当我们遍历census数组提取各个区域时,还可以计算一些额外的数值。

这是我们之前代码片段的扩展版本,包含了这些额外的计算。

   **var** total2010 = 0;
   **var** total2012 = 0;
   **var** south = {
       data: [],
       labels: [],
       growth: [],
       minGrowth: 100,
       maxGrowth: -100
   };
   **for** (**var** i=0; i<census.length; i++) {
➊     total2010 += census[i].pop2010;
➋     total2012 += census[i].pop2012;
➌     **var** growth = (census[i].pop2012 - census[i].pop2010)/census[i].pop2010;
       **if** (census[i].region === "South") {
           south.data.push(census[i].pop2012);
           south.labels.push(census[i].state);
           south.growth.push(growth);
➍         **if** (growth > south.maxGrowth) { south.maxGrowth = growth; }
➎         **if** (growth < south.minGrowth) { south.minGrowth = growth; }
       }
       *// Code continues...*
   }

让我们来逐步讲解这些额外的计算:

  • 我们分别在➊和➋处累积了 2010 年和 2012 年的各州总人口数据。这些值使我们能够计算整个国家的平均增长率。

  • 对于每个州,我们可以在➌处计算其增长率。

  • 对于每个区域,我们分别在➍和➎处保存最小和最大增长率。

就像我们为数据和标签创建了一个主对象一样,我们为增长率创建了另一个主对象。我们还将计算全国的总增长率。

**var** growth = [ west.growth, midwest.growth, northeast.growth, south.growth ];
**var** totalGrowth = (total2012 - total2010)/total2010;

现在我们需要一个函数来计算树形图矩形的颜色。我们首先定义两个颜色范围,一个用于高于全国平均水平的增长率,另一个用于低于全国平均水平的增长率。然后,我们可以根据每个州的增长率,为其选择一个合适的颜色。例如,以下是一组可能的颜色。

**var** colorRanges = {
  positive: [ "#FFFFBF","#D9EF8B","#A6D96A","#66BD63","#1A9850","#006837" ],
  negative: [ "#FFFFBF","#FEE08B","#FDAE61","#F46D43","#D73027","#A50026" ]
};

接下来是pickColor()函数,它使用这些颜色范围为每个框选择合适的颜色。treemap-squared 库会用两个参数调用它——一个是它即将绘制的矩形的坐标,另一个是数据集中的索引。在我们的示例中,我们不需要坐标,但我们会使用索引来找到需要建模的值。一旦我们找到州的增长率,我们可以减去全国平均增长率。这个计算决定了使用哪个颜色范围。增长速度快于全国平均水平的州将使用正向颜色范围;增长速度慢于平均水平的州将使用负向颜色范围。

代码的最后部分计算了在适当的颜色范围内选择颜色的位置。

**function** pickColor(coordinates, index) {
    **var** regionIdx = index[0];
    **var** stateIdx = index[1];
    **var** growthRate = growth[regionIdx][stateIdx];
    **var** deltaGrowth = growthRate - totalGrowth;
    **if** (deltaGrowth > 0) {
        colorRange = colorRanges.positive;
    } **else** {
        colorRange = colorRanges.negative;
        deltaGrowth = -1 * deltaGrowth;
    }
    **var** colorIndex = Math.floor(colorRange.length*(deltaGrowth-minDelta)/
(maxDelta-minDelta));
    **if** (colorIndex >= colorRange.length) { colorIndex = colorRange.length - 1;
}
    color = colorRange[colorIndex];
    **return**{ "fill" : color };
}

该代码使用一个基于所有州中极值的线性刻度。因此,例如,如果某个州的增长率介于整体平均值和最大增长率之间,我们将为其指定一个位于正向颜色范围数组中间的颜色。

现在,当我们调用TreeMap.draw()时,可以将此函数添加到其参数中,具体通过将其设置为选项对象中box键的值。treemap-squared 库将会调用我们的函数来选择区域的颜色。

Treemap.draw("treemap", 600, 450, data, labels, {"box" : pickColor});

结果树形图图 4-2 仍然显示了所有州的相对人口数。现在,通过使用颜色阴影,它还显示了与全国平均水平相比的人口增长率。该可视化清楚地展示了从东北部和中西部到南部和西部的迁移。

树形图可以使用颜色和面积显示数据值。图 4-2.树形图可以使用颜色和面积显示数据值。

使用热图突出显示区域

如果你从事网页行业,热图可能已经是你工作的一部分。可用性研究人员通常使用热图来评估网站设计,特别是在他们想要分析网页的哪些部分获得用户最多关注时。热图通过将半透明的颜色值叠加在二维区域上来工作。如图 4-3 所示,不同的颜色代表不同的关注程度。用户最关注的是红色区域,其次是黄色、绿色和蓝色区域。

在这个示例中,我们将使用热图来可视化篮球比赛中的一个重要方面:球队得分最多的区域。我们将使用的软件是 Patrick Wied 的 heatmap.js 库(www.patrick-wied.at/static/heatmapjs/)。如果你需要创建传统的网页热图,该库已经内置了捕捉鼠标移动和点击的功能,适用于网页。如果你不需要这些功能,可以忽略它们,通用的方法是类似的。

热图传统上展示了网页用户集中注意的地方。图 4-3. 热图传统上展示了网页用户集中注意的地方。

第一步:包含所需的 JavaScript

对于现代浏览器,heatmap.js 库没有额外的要求。该库包含了用于实时热图和地理集成的可选插件,但在我们的示例中不需要这些功能。较旧的浏览器(主要是 IE8 及更早版本)可以通过explorer canvas库使用 heatmap.js。由于我们不希望所有用户都加载这个库,我们将通过条件注释仅在需要时加载它。遵循当前最佳实践,我们将所有脚本文件放在<body>标签的末尾。

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>**
    **<meta** charset="utf-8"**>**
    **<title></title>**
  **</head>**
  **<body>**
    *<!--[if lt IE 9]><script src="js/excanvas.min.js"></script><![endif]-->*
    **<script** src="js/heatmap.js"**></script>**
  **</body>**
**</html>**

第二步:定义可视化数据

在我们的示例中,我们将可视化 2013 年 2 月 13 日 NCAA 男子篮球比赛中,杜克大学与北卡罗来纳大学之间的比赛。我们的数据集(www.cbssports.com/collegebasketball/gametracker/live/NCAAB_20130213_UNC@DUKE)包含了比赛中每个得分的详细信息。为了清洗数据,我们将每个得分的时间转换为从比赛开始后的分钟数,并定义得分者的坐标位置。我们已经根据几个重要的约定定义了这些坐标:

  • 我们将把北卡罗来纳大学的得分显示在球场的左侧,杜克大学的得分显示在右侧。

  • 球场的左下角对应位置(0,0),右上角对应位置(10,10)。

  • 为了避免将罚球与投篮得分混淆,我们为所有罚球指定了位置(–1, –1)。

这是数据的开始部分;完整的数据可以在书籍的源代码中找到(jsDataV.is/source/)。

**var** game = 
  { team: "UNC",  points: 2, time: 0.85, unc: 2, duke: 0, x: 0.506, y: 5.039 },
  { team: "UNC",  points: 3, time: 1.22, unc: 5, duke: 0, x: 1.377, y: 1.184 },
  { team: "DUKE", points: 2, time: 1.65  unc: 5, duke: 2, x: 8.804, y: 7.231 },
  *// Data set continues...*

第 3 步:创建背景图像

一个简单的篮球场图示,类似于[图 4-4,非常适合我们的可视化。背景图像的尺寸是 600×360 像素。

背景图像为可视化提供了背景。图 4-4. 背景图像为可视化提供了背景。

第 4 步:预留一个 HTML 元素来容纳可视化

在我们的网页中,我们需要定义一个元素(通常是 <div>),用于容纳热力图。当我们创建这个元素时,我们指定它的尺寸,并定义背景。下面的代码片段通过内联样式实现了这两个功能,以保持示例简洁。在实际实现中,你可能想使用 CSS 样式表。

**<div** id="heatmap"
    style="position:relative;width:600px;height:360px;
               background-image:url('img/basketball.png');"**>**
**</div>**

注意,我们已经为元素指定了一个唯一的 id。heatmap.js 库需要这个 id 来将热力图放置在页面上。最重要的是,我们还将 position 属性设置为 relative。heatmap.js 库使用绝对定位来放置图形,而我们希望将这些图形包含在父元素内。

第 5 步:格式化数据

对于我们的下一步,我们必须将比赛数据转换为库所需的正确格式。heatmap.js 库要求每个数据点包含三个属性:

  • x 坐标,从包含元素的左侧以像素为单位测量

  • y 坐标,从包含元素的顶部以像素为单位测量

  • 数据点的幅度(由 count 属性指定)

该库还需要整个地图的最大幅度,在这里事情有点复杂。对于标准热力图,任何特定位置的数据点幅度会相加。在我们的例子中,这意味着所有由上篮和灌篮得分的篮筐——这些实际上是来自球场同一位置的——会被热力图算法加在一起。这个位置,正好在篮筐下方,占据了球场的主导地位。为了抵消这个效果,我们指定一个远小于热力图预期的最大值。在我们的案例中,我们将最大值设置为 3,这意味着任何得分超过三分的地方都会被标记为红色,我们将轻松看到所有的篮筐。

我们可以使用 JavaScript 将 game 数组转换为适当的格式。

➊ **var** docNode = document.getElementById("heatmap");
➋ **var** height = docNode.clientHeight;
➌ **var** width  = docNode.clientWidth;
➍ **var** dataset = {};
➎ dataset.max = 3;
➏ dataset.data = [];
   **for** (**var** i=0; i<game.length; i++) {
       **var** currentShot = game[1];
➐      **if** ((currentShot.x !== -1) && (currentShot.y !== -1)) {
           **var** x = Math.round(width * currentShot.x/10);
           **var** y = height - Math.round(height * currentShot.y/10);
           dataset.data.push({"x": x, "y": y, "count": currentShot.points});
       }
   }

我们首先获取包含元素的高度和宽度,分别在 ➊、➋ 和 ➌ 处。如果这些尺寸发生变化,我们的代码仍然能够正常工作。接着,我们初始化 dataset 对象 ➍,并设置一个 max 属性 ➎ 和一个空的 data 数组 ➏。最后,我们遍历比赛数据并将相关数据点添加到这个数组中。注意,我们在 ➐ 处排除了罚球。

步骤 6:绘制地图

通过一个包含元素和格式化的数据集,绘制热力图非常简单。我们通过指定包含元素、每个点的半径和透明度来创建热力图对象(该库为了显得聪明,使用了h337这个名称)。然后,我们将数据集添加到这个对象中。

**var** heatmap = h337.create({
    element: "heatmap",
    radius: 30,
    opacity: 50
});
heatmap.store.setDataSet(dataset);

图 4-5 中的可视化结果展示了每个团队得分的位置。

热力图显示了比赛中的成功投篮。图 4-5:热力图显示了比赛中的成功投篮。

步骤 7:调整热力图的 z-index

heatmap.js 库在操作 z-index 属性时尤其激进。为了确保热力图出现在页面的所有其他元素之上,库将此属性显式设置为 10000000000。如果你的网页上有一些不希望被热力图遮挡的元素(例如固定位置的导航菜单),那么这个值可能太过激进。你可以通过直接修改源代码来修复它。或者,作为替代方案,你可以在库绘制完地图后简单地重置这个值。

如果你正在使用 jQuery,以下代码将把 z-index 调整为一个更合理的值。

$("#heatmap canvas").css("z-index", "1");

使用网络图显示关系

可视化不总是专注于实际的数据值;有时候,数据集中最有趣的方面是其成员之间的关系。例如,社交网络中成员之间的关系可能是该网络最重要的特征。为了可视化这些类型的关系,我们可以使用网络图。网络图将对象(通常称为节点)表示为点或圆圈。线条或弧线(技术上称为)连接这些节点以表示关系。

构建网络图可能有点棘手,因为其中的数学原理并不总是简单的。幸运的是,Sigma 库(sigmajs.org/)处理了大部分复杂的计算。通过使用该库,我们只需一点点 JavaScript 就能创建功能齐全的网络图。对于我们的示例,我们将考虑一位评论家的“有史以来最伟大的 25 张爵士专辑”列表(www.thejazzresource.com/top_25_jazz_albums.html)。几位音乐家在多张专辑上有演出,而网络图可以帮助我们探索这些连接。

步骤 1:包含所需的库

Sigma 库不依赖于其他 JavaScript 库,因此我们不需要包含任何其他脚本。然而,它并不在常见的内容分发网络上提供。因此,我们必须从自己的 Web 主机上提供该库。

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
     **</head>**
     **<body>**
➊     **<div** id="graph"**></div>**
➋     **<script** src="js/sigma.min.js"**></script>**
     **</body>**
   **</html>**

正如你所看到的,我们在➊处为我们的图形预留了一个<div>。我们还在➋处将 JavaScript 库作为<body>元素的最后一部分包含进去,因为这样能提供最佳的浏览器性能。

注意

在本书的大多数示例中,我包含了使你的可视化与旧版网页浏览器(如 IE8)兼容的步骤。然而,在这种情况下,这些方法会严重降低性能,因此几乎无法使用。为了查看网络图可视化,用户需要使用现代浏览器。

第 2 步:准备数据

我们关于排名前 25 的爵士专辑的数据如下所示。我这里只展示了前几张专辑,你可以在书的源代码中查看完整的列表(jsDataV.is/source/)。

**var** albums = [
  {
    album: "Miles Davis - Kind of Blue",
    musicians: [
      "Cannonball Adderley",
      "Paul Chambers",
      "Jimmy Cobb",
      "John Coltrane",
      "Miles Davis",
      "Bill Evans"
    ]
  },{
    album: "John Coltrane - A Love Supreme",
    musicians: [
      "John Coltrane",
      "Jimmy Garrison",
      "Elvin Jones",
      "McCoy Tyner"
    ]
  *// Data set continues...*

那并不是 Sigma 所要求的结构。我们可以将其批量转换为 Sigma JSON 数据结构,但实际上没有必要。相反,正如我们在下一步中将看到的,我们可以一次性将数据传递给库中的每个元素。

第 3 步:定义图的节点

现在我们准备好使用库来构建我们的图形了。我们首先初始化库,并指明它应该在哪里构建图形。该参数是用于承载可视化的<div>元素的id

**var** s = **new** sigma("graph");

现在我们可以继续,将节点添加到图中。在我们的例子中,每个专辑都是一个节点。当我们向图中添加一个节点时,我们为它赋予一个唯一标识符(必须是字符串)、一个标签和一个位置。确定初始位置对于任意数据来说可能有些棘手。在接下来的步骤中,我们会查看一种方法,使初始位置变得不那么关键。不过现在,我们将通过基本的三角函数把专辑均匀分布在一个圆圈上。

**for** (**var** idx=0; idx<albums.length; idx++) {
    **var** theta = idx*2*Math.PI / albums.length;
    s.graph.addNode({
        id: ""+idx,    *// Note: 'id' must be a string*
        label: albums[idx].album,
        x: radius*Math.sin(theta),
        y: radius*Math.cos(theta),
        size: 1
    });
}

这里,radius的值大致是容器宽度的一半。我们也可以给每个节点设置不同的大小,但在我们的应用场景中,将每张专辑的大小设置为1就足够了。

最后,在定义了图形后,我们告诉库绘制它。

s.refresh();

通过图 4-6,我们现在拥有了一个精美绘制的圈,表示所有时间里排名前 25 的爵士专辑。在这个初步尝试中,某些标签可能会相互重叠,但我们稍后会解决这个问题。

如果你在浏览器中尝试此可视化,你会注意到 Sigma 库自动支持平移图形,用户可以将鼠标指针移到单个节点上以高亮显示节点标签。

Sigma 将图的节点绘制为小圆圈。图 4-6. Sigma 将图的节点绘制为小圆圈。

第 4 步:用边连接节点

现在我们已经在一个圆圈中绘制了节点,接下来是时候用边连接它们了。在我们的例子中,一条边——即两个专辑之间的连接——代表了在这两张专辑上都有演奏的音乐家。以下是找到这些边的代码。

➊ **for** (**var** srcIdx=0; srcIdx<albums.length; srcIdx++) {
       **var** src = albums[srcIdx];
➋     **for** (**var** mscIdx=0; mscIdx<src.musicians.length; mscIdx++) {
           **var** msc = src.musicians[mscIdx];
➌         **for** (**var** tgtIdx=srcIdx+1; tgtIdx<albums.length; tgtIdx++) {
               **var** tgt = albums[tgtIdx];
➍             **if** (tgt.musicians.some(**function**(tgtMsc) {**return** tgtMsc === msc;}))
   {
                   s.graph.addEdge({
                       id: srcIdx + "." + mscIdx + "-" + tgtIdx,
                       source: ""+srcIdx,
                       target: ""+tgtIdx
                   })
               }
           }
       }
   }

为了找到边,我们将通过四个阶段迭代这些专辑。

  1. 循环遍历每个专辑,作为潜在的连接源,在➊处。

  2. 对于源专辑,循环遍历➋处的所有音乐人。

  3. 对于每个音乐人,循环遍历剩余的所有专辑,作为潜在的连接目标,在➌处。

  4. 对于每个目标专辑,循环遍历➌处的所有音乐人,寻找匹配项。

在最后一步,我们使用 JavaScript 数组的 .some() 方法。该方法接受一个函数作为参数,如果该函数对数组中的任何元素返回 true,则返回 true

我们希望在刷新图表之前插入这段代码。完成后,我们将得到一个连接的专辑圆形布局,如图 4-7 所示。

Sigma 可以通过线条连接图中的节点,表示边。图 4-7。Sigma 可以通过线条连接图中的节点,表示边。

同样,你可以平移和缩放图表,聚焦于不同的部分。

第五步:自动化布局

到目前为止,我们已经手动将节点放置在图表的圆形布局中。这并不是一个糟糕的方法,但它可能会让我们难以分辨某些连接。如果我们能让库计算一个比简单圆形更优化的布局,那就更好了。现在我们将正是这样做。

这种方法背后的数学原理被称为 力导向图形。简而言之,算法通过将图的节点和边视为受真实物理力(如重力和电磁力)作用的物体来进行处理。它模拟这些力的效果,将节点推入图中新的位置。

底层算法可能比较复杂,但 Sigma 使其变得容易使用。首先,我们需要将可选的 forceAtlas2 插件添加到 Sigma 库中。

<!DOCTYPE html>
**<html** lang="en"**>**
    **<head>**
        **<meta** charset="utf-8"**>**
        **<title></title>**
    **</head>**
    **<body>**
        **<div** id="graph"**></div>**
        **<script** src="js/sigma.min.js"**></script>**
        **<script** src="js/sigma.layout.forceAtlas2.min.js"**></script>**
    **</body>**
**</html>**

Mathieu Jacomy 和 Tommaso Venturini 开发了这个插件使用的具体力方向算法;他们在 2011 年的论文《ForceAtlas2,一个用于方便网络可视化的图形布局算法》中记录了这个算法,论文可以在 webatlas.fr/tempshare/ForceAtlas2_Paper.pdf 中找到。尽管我们不需要理解算法的数学细节,但了解如何使用它的参数是非常有用的。对于大多数使用此插件的可视化,有三个参数是非常重要的:

  • gravity。该参数决定了算法在多大程度上尝试防止孤立节点漂移到屏幕边缘。如果没有任何重力作用,孤立节点上唯一起作用的力就是排斥它们远离其他节点的力;这种力将不受阻碍地把节点推到屏幕之外。由于我们的数据中包含几个孤立节点,我们希望将此值设置得较高,以确保这些节点停留在屏幕上。

  • scalingRatio。这个参数决定了节点之间相互排斥的强度。较小的值会使连接的节点更靠近,而较大的值则会强制所有节点之间距离更远。

  • slowDown。这个参数减少节点对来自邻居的排斥力的敏感度。通过增加这个值来减少敏感度,有助于减少当节点面临来自多个邻居的相互作用力时可能导致的不稳定性。在我们的数据中,存在许多连接会将节点拉近,并与将节点拉开的力产生竞争。为了减缓可能出现的剧烈振荡,我们将这个值设得相对较高。

确定这些参数值的最佳方法是对实际数据进行实验。我们为这个数据集选定的参数值如下代码所示。

s.startForceAtlas2({gravity:100,scalingRatio:70,slowDown:100});
setTimeout(**function**() { s.stopForceAtlas2(); }, 10000);

现在,当我们准备显示图形时,不仅仅是简单地刷新图形,我们开始了力导向算法,它在执行模拟的同时定期刷新显示。我们还需要在算法运行一段时间后停止它。对于我们的情况来说,10 秒(10000毫秒)就足够了。

结果,我们的专辑最初处于它们的原始圆形位置,但很快就迁移到一个更易于识别连接的位置。一些排名靠前的专辑紧密连接,表明它们有很多共同的音乐人。然而,少数专辑依然是孤立的,它们的音乐人只出现在列表中一次。

如图 4-8 所示,节点的标签仍然相互干扰;我们将在下一步修复这个问题。不过这里重要的是,识别出那些具有大量连接的专辑要容易得多。代表这些专辑的节点已经迁移到图形的中心,并且它们与其他节点有许多连接。

力导向自动定位图形节点。图 4-8。力导向自动定位图形节点。

第 6 步:添加交互性

为了避免标签相互干扰,我们可以为图形添加一些交互功能。默认情况下,我们将完全隐藏标签,让用户能够在没有干扰的情况下欣赏图形的结构。然后,我们允许他们点击单独的节点以显示专辑标题及其连接。

   **for** (**var** idx=0; idx<albums.length; idx++) {
       **var** theta = idx*2*Math.PI / albums.length;
       s.graph.addNode({
           id: ""+idx, *// Note: 'id' must be a string*
➊         label: "",
➋         album: albums[idx].album,
           x: radius*Math.sin(theta),
           y: radius*Math.cos(theta),
           size: 1
       });
   }

为了抑制初始标签显示,我们修改了在➊处的初始化代码,使得节点具有空白标签。不过,在➋处,我们保存了专辑标题的引用。

现在,我们需要一个响应点击节点元素的函数。Sigma 库正好支持这种类型的函数及其接口。我们只需绑定到clickNode事件。

s.bind("clickNode", **function**(ev) {
    **var** nodeIdx = ev.data.node.id;
    *// Code continues...*
});

在那个函数中,ev.data.node.id 属性给我们提供了用户点击的节点的索引。完整的节点集合可以从 s.graph.nodes() 返回的数组中获得。由于我们只想显示被点击节点的标签(而不是任何其他节点的标签),我们可以遍历整个数组。在每次遍历中,我们要么将 label 属性设置为空字符串(以隐藏它),要么将其设置为 album 属性(以显示它)。

   s.bind("clickNode", **function**(ev) {
       **var** nodeIdx = ev.data.node.id;
       **var** nodes = s.graph.nodes();
       nodes.forEach(**function**(node) {
➊         **if** (nodes[nodeIdx] === node) {
               node.label = node.album;
           } **else** {
               node.label = "";
           }
       });
   });

现在用户已经有了展示专辑标题的方法,让我们再提供一个隐藏标题的方法。在 ➊ 处做一个小的修改,就能让用户通过后续点击切换专辑显示。

**if** (nodes[nodeIdx] === node && node.label !== node.album) {

既然我们已经让图表响应点击事件,那么我们也可以利用这个机会来突出显示被点击节点的连接。我们通过改变它们的颜色来实现这一点。就像 s.graph.nodes() 返回图表节点的数组一样,s.graph.edges() 返回的是边的数组。每个边对象都包括 targetsource 属性,分别存储相关节点的索引。

   s.graph.edges().forEach(**function**(edge) {
       **if** ((nodes[nodeIdx].label === nodes[nodeIdx].album) &&
➊         ((edge.target === nodeIdx) || (edge.source === nodeIdx))) {
➋         edge.color = "blue";
       } **else** {
➌         edge.color = "black";
       }
   });

在这里,我们扫描图表的所有边,查看它们是否与点击的节点相连。如果边确实连接到该节点,我们会在 ➋ 处将其颜色更改为不同于默认的颜色。否则,我们会在 ➌ 处将颜色恢复为默认值。可以看到,我们使用了与之前切换节点标签相同的方法来切换边的颜色,都是通过连续点击来实现的,位于 ➊。

现在我们已经修改了图表的属性,我们需要告诉 Sigma 重新绘制它。这只是调用 s.refresh() 的简单问题。

s.refresh();

现在,我们已经有了一个完全交互式的网络图,见 图 4-9。

通过词云揭示语言模式

数据可视化并不总是关注数字。有时,数据可视化的焦点是单词,词云往往是展示这类数据的有效方式。词云可以将任意数量与单词列表关联起来;最常见的是,那个数量表示的是相对频率。这种类型的词云,我们将在下一个例子中创建,用来揭示哪些单词常见,哪些单词稀有。

交互式图表为用户提供了突出特定节点的机会。图 4-9. 交互式图表为用户提供了突出特定节点的机会。

要创建这个可视化效果,我们将依赖 wordcloud2 库 (timdream.org/wordcloud2.js),这是作者 Tim Dream 的 HTML5 词云项目 (timc.idv.tw/wordcloud/)的衍生项目。

注意

像我们之前检查过的几个更高级的库一样,wordcloud2 在旧版浏览器(如 IE8 及更早版本)中表现不佳。由于 wordcloud2 本身需要现代浏览器,因此在这个例子中我们不需要担心与旧版浏览器的兼容性。这也让我们可以使用其他一些现代 JavaScript 特性。

第一步:包含所需的库

wordcloud2 库不依赖任何其他 JavaScript 库,因此我们不需要其他包含的脚本。然而,它并未在常见的内容分发网络上提供,因此我们必须从我们自己的网络主机提供它。

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>**
    **<meta** charset="utf-8"**>**
    **<title></title>**
  **</head>**
  **<body>**
    **<script** src="js/wordcloud2.js"**></script>**
  **</body>**
**</html>**

为了让我们的示例专注于可视化部分,我们将使用一个不需要特别准备的单词列表。然而,如果你正在处理口语或书面自然语言,你可能希望处理文本,以识别同一单词的不同形式。例如,你可能想将holdholdsheld视为同一个单词hold的三种形式,而不是三个不同的词。这种处理显然在很大程度上取决于具体的语言。不过,如果你在使用英语和中文,那么创建 wordcloud2 的开发者也发布了 WordFreq JavaScript 库(timdream.org/wordfreq/),它正是执行这种类型分析的工具。

第二步:准备数据

在这个示例中,我们将查看用户在流行的 Stack Overflow 网站上与他们问题相关的不同标签(stackoverflow.com/)。该网站允许用户提出编程问题,社区则尝试回答这些问题。标签为问题提供了一种方便的分类方式,使得用户能够浏览与相同主题相关的其他帖子。通过构建词云(也许更好地称之为标签云),我们可以快速展示不同编程主题的相对受欢迎程度。

如果你想将这个示例发展成一个真实的应用程序,你可以通过该网站的 API 实时访问 Stack Overflow 的数据。然而,对于我们的示例,我们将使用一个静态快照。下面是如何开始的:

**var** tags = [
    ["c#", 601251],
    ["java", 585413],
    ["javascript", 557407],
    ["php", 534590],
    ["android", 466436],
    ["jquery", 438303],
    ["python", 274216],
    ["c++", 269570],
    ["html", 259946],
    *// Data set continues...*

在这个数据集中,标签列表是一个数组,列表中的每个标签也是一个数组。这些内部数组的第一个项目是单词本身,第二个项目是该单词的计数。你可以在书籍的源代码中查看完整的列表(jsDataV.is/source/)。

wordcloud2 所期望的格式与我们当前数据的布局非常相似,唯一不同的是,在每个单词数组中,第二个值需要指定该单词的绘制大小。例如,数组元素["javascript", 56]会告诉 wordcloud2 将javascript绘制为 56 像素的高度。当然,我们的数据没有以像素大小设置。javascript的计数值是557407,一个高度为 557,407 像素的单词甚至无法放进广告牌上。因此,我们必须将计数转换为绘制大小。具体的转换算法将取决于可视化的大小以及原始数据值。一个在此案例中有效的简单方法是将计数值除以 10,000 并四舍五入到最接近的整数。

**var** list = tags.map(**function**(word) {
    **return** [word[0], Math.round(word[1]/10000)];
});

在第二章中,我们看到 jQuery 的 .map() 函数如何让处理数组中的所有元素变得非常容易。事实证明,现代浏览器内建了相同的功能,所以在这里我们即使没有 jQuery,也使用了 .map() 的原生版本。(这个原生版本在较旧的浏览器上无法像 jQuery 那样工作,但我们在这个示例中不需要担心这个问题。)

在这段代码执行后,我们的 list 变量将包含以下内容:

[
    ["c#", 60],
    ["java", 59],
    ["javascript", 56],
    ["php", 53],
    ["android", 47],
    ["jquery", 44],
    ["python", 27],
    ["c++", 27],
    ["html", 26],
    *// Data set continues...*

第 3 步:添加必要的标记

wordcloud2 库可以使用 HTML <canvas> 接口或纯 HTML 来构建图形。如同我们在许多图形库中看到的,<canvas> 是一个非常方便的接口,用于创建图形元素。然而,对于词云来说,使用 <canvas> 并没有太多好处。另一方面,原生 HTML 允许我们使用所有标准的 HTML 工具(如 CSS 样式表或 JavaScript 事件处理)。在这个示例中,我们将采用这种方法。

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
     **</head>**
     **<body>**
➊     **<div** id="cloud" style="position:relative;"**></div>**
       **<script** src="js/wordcloud2.js"**></script>**
     **</body>**
   **</html>**

在使用原生 HTML 时,我们确实需要确保包含元素具有 position: relative 样式,因为 wordcloud2 在将单词放置到云中合适位置时依赖于这个样式。你可以看到,在这里我们已经在 ➊ 处设置了这个样式。

第 4 步:创建一个简单的云

准备好这些之后,创建一个简单的词云就变得非常容易了。我们调用 wordcloud2 库并告诉它绘制云的 HTML 元素,以及云的数据单词列表。

WordCloud(document.getElementById("cloud"), {list: list});

即使只有默认值,wordcloud2 也会创建出如图 4-10 所示的吸引人的可视化效果。

wordcloud2 接口还提供了许多自定义可视化效果的选项。正如预期的那样,你可以设置颜色和字体,但你还可以改变云的形状(甚至提供自定义的极坐标方程)、旋转限制、内部网格大小以及许多其他功能。

词云可以显示单词及其相对频率。图 4-10. 词云可以显示单词及其相对频率。

第 5 步:添加交互性

如果你要求 wordcloud2 使用 <canvas> 接口,它会提供一些回调钩子,供你的代码响应用户的交互。然而,在原生 HTML 中,我们不仅仅局限于 wordcloud2 提供的回调。为了演示这一点,我们可以添加一个简单的交互,来响应鼠标点击云中单词的操作。

首先,我们会让用户知道支持交互,当他们将鼠标悬停在云中的单词上时,我们会将鼠标指针更改为指针形态。

#cloud span **{**
    **cursor:** pointer**;**
**}**

接下来,让我们在标记中添加一个额外的元素,用于显示关于任何被点击单词的信息。

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
     **</head>**
     **<body>**
       **<div** id="cloud" style="position:relative;"**></div>**
➊     **<div** id="details"**><div>**
       **<script** src="js/wordcloud2.js"**></script>**
     **</body>**
   **</html>**

这里我们在 ➊ 处添加了带有 id details<div> 元素。

然后我们定义一个函数,当用户点击云中的某个位置时可以调用该函数。

   **var** clicked = **function**(ev) {
➊     **if** (ev.target.nodeName === "SPAN") {
           *// A <span> element was the target of the click*
       }
   }

由于我们的函数会在云容器中的任何点击事件(包括点击空白区域)触发时被调用,因此它首先检查点击的目标是否真的为一个单词。单词被包含在 <span> 元素中,因此我们可以通过查看点击目标的 nodeName 属性来验证这一点。如你在 ➊ 处所见,JavaScript 的节点名称总是大写。

如果用户确实点击了一个单词,我们可以通过查看事件目标的 textContent 属性来找出是哪个单词。

   **var** clicked = **function**(ev) {
       **if** (ev.target.nodeName === "SPAN") {
➊         **var** tag = ev.target.textContent;
       }
   }

在 ➊ 之后,变量 tag 将保存用户点击的单词。因此,例如,如果用户点击了单词 javascript,那么 tag 变量将会有值 "javascript"

由于我们希望在用户点击单词时显示总数,我们需要在原始数据集中查找该单词。我们已经有了单词的值,因此只需通过数据集进行搜索,找到匹配的项。如果我们使用的是 jQuery,.grep() 函数就能做到这一点。在这个示例中,我们坚持使用原生 JavaScript,因此我们需要寻找一个原生 JavaScript 中的等效方法。不幸的是,尽管已经定义了这样的原生方法——.find()——但目前很少有浏览器(即使是现代浏览器)支持它。我们可以采用标准的 forforEach 循环,但也有一种替代方案,许多人认为这种方法优于前者。它依赖于现代浏览器支持的 .some() 方法。.some() 方法会将数组中的每个元素传递给一个任意函数,当该函数返回 true 时停止。下面是我们如何使用它来查找点击的标签在 tags 数组中的位置。

   **var** clicked = **function**(ev) {
       **if** (ev.target.nodeName === "SPAN") {
           **var** tag = ev.target.textContent;
           **var** clickedTag;
➊        tags.some(**function**(el) {
➋            **if** (el[0] === tag) {
                   clickedTag = el;
                   **return** **true**;  *// This ends the .some() loop*
              }
➌            **return** **false**;
➍        });
       }
   }

作为 .some() 参数的函数定义从 ➊ 开始,到 ➍ 结束。该函数以 el 为参数,eltags 数组中的一个 元素 的缩写。➋ 处的条件语句检查该元素的单词是否与点击节点的文本内容匹配。如果匹配,该函数会设置 clickedTag 变量并返回 true,以终止 .some() 循环。

如果点击的单词与我们在 tags 数组中检查的元素不匹配,那么传给 .some() 的函数会在 ➌ 返回 false。当 .some() 遇到 false 的返回值时,它会继续遍历数组。

我们可以使用 .some() 方法的返回值来确保点击的元素确实在数组中找到了。当发生这种情况时,.some() 本身会返回 true

   **var** clicked = **function**(ev) {
     **var** details = "";

     **if** (ev.target.nodeName === "SPAN") {
         **var** tag = ev.target.textContent,
             clickedTag;
         **if** (tags.some(**function**(el) {
             **if** (el[0] === tag) {
                   clickedTag = el;
                   **return** **true**;
             }
             **return** **false**;
         })) {
➊           details = "There were " + clickedTag[1] +
➋                     " Stack Overflow questions tagged \"" + tag + "\"";
         }
     }
➌   document.getElementById("details").innerText = details;
   }

在 ➊ 和 ➋ 处,我们用额外的信息更新了 details 变量。在 ➌ 处,我们用这些信息更新了网页。

最后,我们告诉浏览器,当用户点击云容器中的任何内容时,调用我们的处理程序。

document.getElementById("cloud").addEventListener("click", clicked)

通过这几行代码,我们的词云现在变得互动了,如 图 4-11 所示。

由于我们的词云由标准 HTML 元素组成,因此我们可以通过简单的 JavaScript 事件处理器使其具有交互性。图 4-11。由于我们的词云由标准 HTML 元素组成,因此我们可以通过简单的 JavaScript 事件处理器使其具有交互性。

总结

在本章中,我们探讨了几种不同的专用可视化图表和一些可以帮助我们创建它们的 JavaScript 库。树状图非常适合在单一可视化中展示层级关系和维度信息。热力图可以突出显示区域内的不同强度。网络图揭示了对象之间的连接关系。而词云则通过一种吸引人且简洁的可视化方式展示语言属性之间的相对关系。

第五章:显示时间线

最具吸引力的可视化通常之所以成功,是因为它们讲述了一个故事;它们从数据中提取出一个叙事,并向用户展示这个故事。与任何叙事一样,时间是一个关键组成部分。如果数据仅由数字组成,标准的条形图或折线图可以轻松地展示其随时间的演变。然而,如果数据不是数值型的,标准图表可能就无法奏效。本章将考虑几种基于时间的可视化替代方案。

所有这些都是基于某种形式的时间线;其中一个线性维度表示时间,事件根据发生的时间在该维度上标出。在所有示例中,我们将考虑相同的基础数据:威廉·莎士比亚戏剧的可能年表 (en.wikipedia.org/wiki/Chronology_of_Shakespeare%27s_plays).

我们将展示三种非常不同的方法来向网页添加时间线。一种方法依赖于一个 JavaScript 库,并且它的过程与本书中的许多其他可视化方法相似。然而,另外两种技术则提供了不同的视角。其中一种方法,我们完全不使用可视化库。相反,我们将使用基础的 JavaScript、HTML 和 CSS 来构建时间线,我们还将展示如何在有和没有 jQuery 的情况下实现。最后一个示例展示了另一种极端方法,它依赖于来自外部网站的一个功能完整的 Web 组件。简而言之,我们将研究以下内容:

  • 如何使用库创建时间线

  • 如何仅使用 JavaScript、HTML 和 CSS 创建时间线而不依赖库

  • 如何在网页中集成时间线组件

使用库构建时间线

首先,我们将使用 Chronoline.js 库来构建时间线 (stoicloofah.github.io/chronoline.js/),它的工作方式与本书中我们使用的其他大多数 JavaScript 库类似。你需要在页面中包含该库,定义数据,然后让库来创建可视化。

步骤 1:包含所需的库

Chronoline.js 库本身依赖于一些其他库,我们需要在页面中包含它们。

所有这些库都足够流行,以至于公共内容分发网络能够支持,因此我们将在以下标记中使用 CloudFlare 的 CDN。然而,我们需要使用我们自己的资源来托管 Chronoline.js 库。该库还定义了自己的样式表。

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
       **<link** rel="stylesheet" type="text/css"
             href="//cdnjs.cloudflare.com/ajax/libs/qtip2/2.2.0/jquery.qtip.css"**>**
       **<link** rel="stylesheet" type="text/css"
             href="css/chronoline.css"**>**
     **</head>**
     **<body>**
➊   **<div** id="timeline"**></div>**
        **<script** src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.0.3/jquery.min.js"**>**
        **</script>**
        **<script** src="//cdnjs.cloudflare.com/ajax/libs/qtip2/2.2.0/jquery.qtip.min.js"**>**
        **</script>**
        **<script** src="//cdnjs.cloudflare.com/ajax/libs/raphael/2.1.2/raphael-min.js"**>**
        **</script>**
       **<script** src="js/chronoline.js"**></script>**
     **</body>**
   **</html>**

如你在 ➊ 所见,我们已预留了一个 <div> 元素来容纳我们的时间线。我们还将 JavaScript 库放在 <body> 元素的最后部分,因为这样能提供最佳的浏览器性能。

步骤 2:准备数据

我们的时间线数据来自 Wikipedia(en.wikipedia.org/wiki/Chronology_of_Shakespeare%27s_plays)。作为一个 JavaScript 对象,这些数据可能像以下摘录那样结构化:

[
  {
    "play": "The Two Gentlemen of Verona",
    "date": "1589-1591",
    "record": "Francis Meres'...",
    "published": "First Folio (1623)",
    "performance": "adaptation by Benjamin Victor...",
    "evidence": "The play contains..."
}, {
    "play": "The Taming of the Shrew",
    "date": "1590-1594",
    "record": "possible version...",
    "published": "possible version...",
    "performance": "According to Philip Henslowe...",
    "evidence": "Kier Elam posits..."
}, {
    "play": "Henry VI, Part 2",
    "date": "1590-1591",
    "record": "version of the...",
    "published": "version of the...",
    "performance": "although it is known...",
    "evidence": "It is known..."
},
*// Data set continues...*

你可以在书籍的源代码中看到完整的数据集(jsDataV.is/source/)。

在使用 Chronoline.js 之前,我们必须将原始数据转换为库所期望的格式。由于我们可以使用 jQuery,我们可以利用其 .map() 函数进行转换。(有关 .map() 的详细信息,请参见 选择图表内容 中的第七步:根据交互状态确定图表数据。)

   **var** events = $.map(plays, **function**(play) {
       **var** event = {};
       event.title = play.play;
➊     **if** (play.date.indexOf("-") !== -1) {
           **var** daterange = play.date.split("-");
➋         event.dates = [**new** Date(daterange[0], 0, 1),
                          **new** Date(daterange[1], 11, 31)]
       } **else** {
➌          event.dates = [**new** Date(play.date, 0, 1), **new** Date(play.date, 11, 31)]
       }
       **return** event;
   });

如你从我们的数据集中看到的,一些戏剧的日期只有一个年份,而另一些则有一段年份范围(由破折号分隔的两个日期)。为了设置 Chronoline.js 的日期范围,我们在➊检查是否有破折号。如果有,我们将在破折号处拆分日期字符串,并在➋设置多年的范围。否则,我们将在➌设置单一年份范围。

注意

请记住,JavaScript 中的 Date 对象是从 0 开始计算月份,而不是从 1

第三步:绘制时间线

要绘制时间线,我们创建一个新的 Chronoline 对象,传入 HTML 容器元素、事件数据以及任何选项。HTML 容器元素应该是原生元素,而不是 jQuery 选择器。为了将选择器转换为原生元素,我们使用 get() 方法。在这种情况下,我们需要第一个元素,因此使用参数 0

$(**function**() {
    **var** timeline = **new** Chronoline($("#timeline").get(0), events, {});
}

然而,如果我们尝试使用 Chronoline.js 的默认选项来处理我们的数据,结果会相当令人失望。(实际上,它是难以阅读的,现阶段不值得再呈现。)我们可以在下一步通过一些额外的选项来修复这个问题。

第四步:为数据设置 Chronoline.js 选项

Chronoline.js 库有适合其原始应用的默认选项,但对于莎士比亚的戏剧来说并不太合适。幸运的是,我们可以更改这些选项的默认值。截止本文编写时,Chronoline.js 并没有太多关于其选项的文档;要查看完整的选项集,通常需要检查源代码。不过,我们将在这里介绍最重要的几个选项。

Chronoline.js 默认设置中最明显的问题之一是初始视图中显示的日期。Chronoline.js 默认从当前日期开始显示。由于我们的时间线结束于 1613 年,用户必须向后滚动很长时间才能看到有意义的内容。我们可以通过为 Chronoline.js 提供一个不同的起始日期来改变这个视图:

defaultStartDate: **new** Date(1589, 0, 1),

只要我们设置时间线的起点接近莎士比亚的生平,就不需要 Chronoline.js 为当前日期添加特殊标记,因此我们使用这个简单的选项告诉它不要打扰:

markToday: **false**,

接下来需要解决的主要问题是标签。默认情况下,Chronoline.js 尝试为时间线上的每一天添加标签。由于我们的事件跨越了 24 年,我们不需要那么精细的粒度。相反,我们可以告诉 Chronoline.js 只标记年份。出于同样的原因,我们还需要更改勾选标记。我们不需要每天都标记,而是只在每个月标记一次。

要更改这两个选项,我们为 Chronoline.js 提供了一对需要调用的函数。

hashInterval: **function**(date) {
    **return** date.getDate() === 1;
},
labelInterval: **function**(date) {
    **return** date.getMonth() === 0 && date.getDate() === 1;
},

Chronoline.js 将每个这些函数传递一个日期对象,函数根据日期是否值得标记或标签返回 truefalse。对于勾选标记,我们只在每个月的第一天返回 true。对于标签,我们只在每年的 1 月 1 日返回 true

默认情况下,Chronoline.js 会尝试为每个标签显示完整的日期。由于我们只想标记每个年份,我们将标签格式更改为只显示年份。格式规范的详细信息基于标准 C++ 库(* www.cplusplus.com/reference/ctime/strftime/*)。

labelFormat: "%Y",

对于标签的最后一些调整,我们移除了 Chronoline.js 默认添加的“子标签”和“子子标签”。这些标签在我们的情况下没有任何价值。

subLabel: **null**,
subSubLabel: **null**,

我们还希望改变 Chronoline.js 在时间线中显示的时间跨度。对于我们的数据,一次显示五年的跨度似乎很合适。

visibleSpan: DAY_IN_MILLISECONDS * 366 * 5,

请注意,变量 DAY_IN_MILLISECONDS 是由 Chronoline.js 本身定义的。我们可以在此或任何其他选项设置中自由使用它。

现在我们可以处理时间线滚动的问题。Chronoline.js 通常每次点击时推进时间线一天。这样会导致我们的用户滚动时感到非常繁琐。我们将不使用默认行为,而是让 Chronoline.js 每次推进一年。与标签一样,我们通过为 Chronoline.js 提供一个函数来改变这个行为。这个函数接收一个日期对象,并应返回一个新的日期对象,Chronoline.js 应该滚动到该日期。在我们的案例中,我们仅仅是将年份值加或减去 1。

scrollLeft: **function**(date) {
    **return** **new** Date(date.getFullYear() - 1, date.getMonth(), date.getDate());
},
scrollRight: **function**(date) {
    **return** **new** Date(date.getFullYear() + 1, date.getMonth(), date.getDate());
},

最后的几个调整清理了 Chronoline.js 的外观和行为。在时间线的开始和结束之前添加一些额外的空间(在我们的案例中是三个月)给数据留出一点空间。

timelinePadding: DAY_IN_MILLISECONDS * 366 / 4,

我们还可以让滚动平滑地动画化,而不是跳跃,让用户能够左右拖动时间线,并改善默认的浏览器工具提示。

animated: **true**,
draggable: **true**,
tooltips: **true**,

对于最后的调整,我们可以改变时间线的外观。要改变事件的颜色和大小,我们使用以下选项:

eventAttrs: { *// attrs for the bars and circles of the events*
    fill: "#ffa44f",
    stroke: "#ffa44f",
    "stroke-width": 1
},
eventHeight: 10,

要改变滚动按钮的颜色,我们必须修改 chronoline.css 样式表。需要更改的属性是 background-color

.chronoline-left:hover,
.chronoline-right:hover **{**
    **opacity:** 1**;**
    **filter:** alpha(opacity=100)**;**
    **background-color:** #97aceb**;**
**}**

通过这些更改,我们最终得到了莎士比亚剧作的时间线,如 图 5-1 所示。

Chronoline.js 库创建了一个简单的互动时间线。图 5-1. Chronoline.js 库创建了一个简单的互动时间线。

结果时间线看起来相当不错,但库的局限性使得进一步自定义和增强时间线变得困难。接下来,我们将从零开始构建一个新的时间线,而不使用库,以便完全控制。

使用 JavaScript 构建时间线

如果你跟随上一节中的示例,你可能对结果不完全满意。我们确实得到了一个准确的莎士比亚剧作时间线,但生成的可视化效果可能没有传达出你想要的内容。例如,时间线不会显示剧作的名称,除非用户将鼠标悬停在图表的该部分。或许我们更希望剧作的标题始终可见。这类问题是第三方库的局限性。Chronoline.js 的作者没有看到显示标题的必要,因此没有提供这个选项。除非我们愿意承担修改库源代码的艰巨任务,否则我们无法让库完全按照我们的需求工作。

幸运的是,尤其在时间线的情况下,我们可以采用完全不同的方法。我们可以在完全不使用任何第三方库的情况下创建可视化效果,这将让我们对结果拥有完全的控制权。时间线尤其适合这种方法,因为它们只需要文本和样式就能创建。只需基本了解 HTML 和 CSS,加上一些 JavaScript 来设置和可能提供简单的交互。

这正是我们在这个示例中要做的事情。我们将从之前相同的数据集开始。但与将数据输入到第三方库中不同,我们将使用纯 JavaScript(加上可选的 jQuery)来构建数据的纯 HTML 表示。然后,我们将使用 CSS 来设置时间线的外观。

步骤 1:准备 HTML 骨架

在没有任何必需库的情况下,我们的时间线 HTML 页面非常简单。我们只需要一个包含唯一 id 属性的 <div> 元素。

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>**
    **<meta** charset="utf-8"**>**
    **<title></title>**
  **</head>**
  **<body>**
    **<div** id="timeline"**></div>**
  **</body>**
**</html>**

步骤 2:开始 JavaScript 执行

一旦浏览器完成加载我们的页面,我们就可以开始处理数据。和之前一样,我们将从格式化为 JavaScript 数组的数据开始。你可以在书本的源代码中看到完整的数据集 (jsDataV.is/source/).

window.onload = **function** () {
  **var** plays = 
    {
      "play": "The Two Gentlemen of Verona",
      "date": "1589-1591",
      "record": "Francis Meres'...",
      "published": "First Folio (1623)",
      "performance": "adaptation by Benjamin Victor...",
      "evidence": "The play contains..."
    }, {
      "play": "The Taming of the Shrew",
      "date": "1590-1594",
      "record": "possible version...",
      "published": "possible version...",
      "performance": "According to Philip Henslowe...",
      "evidence": "Kier Elam posits..."
    }, {
      "play": "Henry VI, Part 2",
      "date": "1590-1591",
      "record": "version of the...",
      "published": "version of the...",
      "performance": "although it is known...",
      "evidence": "It is known..."
    },
    *// Data set continues...*
}

步骤 3:使用语义化 HTML 创建时间线

要在 HTML 中创建时间轴,我们首先需要决定如何表示它。如果你习惯使用任意的 <div><span> 元素,你可能认为这也是最好的方法。然而,实际上我们应该考虑其他 HTML 结构,它们能够更准确地传达内容。更贴近内容含义的 HTML 被称为 语义化标记,通常优于通用的 <div><span> 标签。语义化标记能够将内容的含义传递给计算机,比如搜索引擎和为视力障碍用户提供服务的屏幕阅读器,并且能够提高网站的搜索排名和可访问性。如果我们从语义化标记的角度考虑时间轴,很容易发现时间轴其实就是一个列表。事实上,它是一个有特定顺序的列表。因此,我们应该将 HTML 时间轴构建为有序列表(<ol>)元素。在创建 <ol> 时,我们还可以为它添加一个类名,以便稍后为其添加 CSS 样式规则。

**var** container = document.getElementById("timeline");
**var** list = document.createElement("ol");
list.className="timeline";
container.appendChild(list);

接下来我们可以遍历这些剧本,为每个剧本创建一个单独的列表项 <li>。目前,我们只需将日期和标题作为文本插入。

plays.forEach(**function**(play) {
    **var** listItem = document.createElement("li");
    listItem.textContent = play.date + ": " + play.play;
    list.appendChild(listItem);
})

[图 5-2 显示了结果列表的简化版本。它看起来可能不算什么(目前),但它包含了必要的数据和结构。

纯 HTML 时间轴可以从一个简单的有序列表开始。 图 5-2. 纯 HTML 时间轴可以从一个简单的有序列表开始。

如果你查看支撑该列表的 HTML 代码,你会发现它相当简单:

**<ol** class="timeline"**>**
    **<li>**1589-1591: The Two Gentlemen of Verona**</li>**
    **<li>**1590-1594: The Taming of the Shrew**</li>**
    **<li>**1590-1591: Henry VI, Part 2**</li>**
    **<li>**1591: Henry VI, Part 3**</li>**
    **<li>**1591: Henry VI, Part 1**</li>**
**</ol>**

本着语义化 HTML 的精神,我们应该停下来考虑一下是否可以改进这些标记。由于它首先出现在我们的列表项中,我们考虑一下剧本的日期或日期范围。尽管这个决策引起了一些争议,但 HTML5 已定义了支持 <time> 元素来包含日期和时间。使用这个元素作为包装器将使我们的日期更具语义性。每个列表项的第二部分是剧本的标题。恰好,HTML5 的 <cite> 元素非常适合这个内容。引用当前标准(html.spec.whatwg.org):

<cite> 元素表示作品的标题(例如,一本书,… 一部剧本,[已强调] …等)。这可以是被引用或详细参考的作品(即引用),也可以是仅仅在旁提及的作品。

为了将这些元素添加到我们的代码中,我们需要区分单一年份的日期和范围日期。通过在数据中查找破折号(-),我们可以知道是哪一种。

   plays.forEach(**function**(play) {
       **var** listItem = document.createElement("li");
       **if** (play.date.indexOf("-") !== -1) {
➊         **var** dates = play.date.split("-");
           **var** time = document.createElement("time");
           time.textContent = dates[0];
           listItem.appendChild(time);
           time = document.createElement("time");
           time.textContent = dates[1];
➋         listItem.appendChild(time);
       } **else** {
           **var** time = document.createElement("time");
           time.textContent = play.date;
           listItem.appendChild(time);
       }
       **var** cite = document.createElement("cite");
       cite.textContent = play.play;
       listItem.appendChild(cite);
       list.appendChild(listItem);
   })

注意我们如何处理日期范围(➊到➋)。由于范围有开始和结束时间,我们创建了两个不同的 <time> 元素。此时,我们不需要在日期之间添加任何标点符号。

因为我们不再包含标点符号,生成的输出(如图 5-3 所示)可能看起来比以前差一些。不过,不用担心,我们很快就会修复它。

语义化标记简化了所需的 HTML,但可能需要特殊样式。图 5-3. 语义化标记简化了所需的 HTML,但可能需要特殊样式。

改进的部分是底层的 HTML。该标记清楚地标识了它包含的内容类型:一个按日期和引用排序的有序列表。

**<ol** class="timeline"**>**
    **<li><time>**1589**</time><time>**1591**</time><cite>**The Two Gentlemen of Verona
    **</cite></li>**
    **<li><time>**1590**</time><time>**1594**</time><cite>**The Taming of the Shrew
    **</cite></li>**
    **<li><time>**1590**</time><time>**1591**</time><cite>**Henry VI, Part 2**</cite></li>**
    **<li><time>**1591**</time><cite>**Henry VI, Part 3**</cite></li>**
    **<li><time>**1591**</time><cite>**Henry VI, Part 1**</cite></li>**
**</ol>**

第 4 步:包含辅助内容

当我们使用 Chronoline.js 库创建时间轴时,我们无法包含来自 Wikipedia 的辅助内容,因为该库没有提供这个选项。然而,在这个例子中,我们可以完全控制内容,因此让我们将这些信息包含在时间轴中。对于大多数剧本,我们的数据包括其首次官方记录、首次出版、首次演出以及证据的讨论。这类内容与 HTML 的描述列表<dl>)完美匹配,因此我们将以这种方式将其添加到页面中。它可以紧跟在剧本标题的<cite>标签后。

plays.forEach(**function**(play) {
    *// Additional code...*
    listItem.appendChild(cite);
    **var** descList = document.createElement("dl");
    *// Add terms to the list here*
    listItem.appendChild(descList);
    list.appendChild(listItem);
})

我们可以定义一个映射数组来帮助将每个术语添加到每个播放中。该数组将我们数据集中的属性名称映射到我们希望在内容中使用的标签。

**var** descTerms = [
    { key: "record",      label: "First official record"},
    { key: "published",   label: "First published"},
    { key: "performance", label: "First recorded performance"},
    { key: "evidence",    label: "Evidence"},
];

有了这个数组,我们可以快速地将描述添加到我们的内容中。我们使用.forEach()方法遍历数组。

   plays.forEach(**function**(play) {
       *// Additional code...*
       listItem.appendChild(cite);
       **var** descList = document.createElement("dl");
       descTerms.forEach(**function**(term) {
➊         **if** (play[term.key]) {
               **var** descTerm = document.createElement("dt");
               descTerm.textContent = term.label;
               descList.appendChild(descTerm);
               **var** descElem = document.createElement("dd");
               descElem.textContent = play[term.key];
               descList.appendChild(descElem);
           }
       });
       listItem.appendChild(descList);
       list.appendChild(listItem);
   })

在每次迭代时,我们确保数据中有内容(➊),然后再创建描述项。描述项包含一个或多个术语,在一个<dt>标签中描述,并在<dd>标签中给出描述。

我们的时间轴仍然缺乏一些视觉吸引力,但如图 5-4 所示,它已经拥有了更丰富的内容。事实上,即使没有任何样式,它仍然能很好地传达基本数据。

以下是生成的标记(为了简洁起见已略去部分内容):

**<ol** class="timeline"**>**
    **<li>**
        **<time>**1589**</time><time>**1591**</time>**
        **<cite>**The Two Gentlemen of Verona**</cite>**
        **<dl>**
            **<dt>**First official record**</dt><dd>**Francis Meres'...**</dd>**
            **<dt>**First published**</dt><dd>**First Folio (1623)**</dd>**
            **<dt>**First recorded performance**</dt><dd>**adaptation by...**</dd>**
            **<dt>**Evidence**</dt><dd>**The play contains...**</dd>**
        **</dl>**
    **</li>**
    **<li>**
        **<time>**1590**</time><time>**1594**</time><cite>**The Taming of the Shrew**</cite>**
        **<dl>**
            **<dt>**First official record**</dt><dd>**possible version...**</dd>**
            **<dt>**First published**</dt><dd>**possible version...**</dd>**
            **<dt>**First recorded performance**</dt><dd>**According to Philip...**</dd>**
            **<dt>**Evidence**</dt><dd>**Kier Elam posits...**</dd>**
        **</dl>**
    **</li>**
**</ol>**

HTML 使得将额外内容添加到列表变得容易。图 5-4. HTML 使得将额外内容添加到列表变得容易。

第 5 步:可选地利用 jQuery

到目前为止,我们的代码只使用了纯 JavaScript。如果你在网页中使用了 jQuery,你可以通过利用一些 jQuery 特性稍微缩短代码。如果你的网页尚未使用 jQuery,这一步的微小增强不足以证明添加它的必要性,但如果你想看到更简洁的版本,可以查看本书的源代码以获取替代方案。

第 6 步:使用 CSS 修复时间轴问题

既然我们已经在 HTML 中构建了时间轴的内容,现在是时候定义决定其外观的样式了。在这个例子中,我们将重点关注样式的功能性方面,而不是纯粹的视觉元素(如字体和颜色),因为你可能希望这些视觉样式专门针对你自己的网站。

第一步是简单的。我们要去除浏览器通常添加到有序列表项中的编号(1、2、3……)。一个规则就能将它们从我们的时间轴中清除:通过将list-style-type设置为none,我们告诉浏览器不要向列表项添加任何特殊字符。

.timeline li **{**
    **list-style-type:** none**;**
**}**

我们还可以使用 CSS 规则为我们的语义化 HTML 添加一些标点符号。首先我们寻找两个<time>元素紧接在一起出现的地方,同时跳过孤立的<time>标签。

.timeline li > time + time:before **{**
    **content:** "-"**;**
**}**

找到<time>配对的诀窍是使用 CSS 相邻选择器+。带有time + time的规则指定了一个紧接着另一个<time>元素的<time>元素。为了添加标点,我们使用:before伪选择器指定我们希望发生的操作这个第二个<time>标签之前,并将content属性设置为指示我们想要插入的内容。

如果你之前没有见过 CSS 规则中的 >,那它是 直接子代选择器。在这个例子中,它意味着<time>元素必须是<li>元素的直接子元素。我们使用这个选择器是为了确保我们的规则不会不小心应用到嵌套在列表项内容中更深位置的其他<time>元素。

为了完成标点符号,我们将在每个列表项中的最后一个<time>元素后添加一个冒号和空格。

.timeline li > time:last-of-type:after **{**
    **content:** ": "**;**
**}**

我们为此规则使用了两个伪选择器。:last-of-type 选择器针对列表项中的最后一个<time>元素。如果只有一个<time>元素,它就是第一个;如果两个都存在,它就是第二个。然后我们添加 :after 伪选择器,在该 <time> 元素之后插入内容。

通过这些更改,我们清除了时间轴中所有明显的问题(见图 5-5)。

CSS 样式让时间轴更易读,而无需更改标记。图 5-5. CSS 样式让时间轴更易读,而无需更改标记。

现在我们可以为可视化增添一些亮点了。

第 7 步:为时间轴添加样式以进行视觉结构化

接下来的 CSS 样式将改善时间轴的视觉结构。首先的改进将使时间轴看起来更像一条线。为此,我们可以在<li>元素的左侧添加边框。同时,我们还需要确保这些<li>元素没有任何外边距,因为外边距会在边框中引入空隙,破坏线条的连续性。

.timeline li **{**
    **border-left:** 2px solid black**;**
**}**
.timeline dl,
.timeline li **{**
    **margin:** 0**;**
**}**

这些样式在我们的整个时间线左侧添加了一条漂亮的竖线。现在我们有了这条竖线,可以将日期移到它的左侧。这个偏移需要为父 <li> 元素以及 <time> 元素分别设置规则。对于父 <li> 元素,我们希望它们的 position 设置为 relative

.timeline li **{**
    **position:** relative**;**
**}**

单独使用这个规则实际上并不会改变我们的时间线。然而,它为任何 <li> 元素的子元素建立了一个定位上下文。这些子元素包括我们想要移动的 <time> 元素。通过将 <li> 设置为 position: relative,我们可以将 <time> 子元素设置为 position: absolute。这个规则允许我们精确指定浏览器应该将时间元素放置在哪里,相对于父元素 <li>。我们希望将所有 <time> 元素移到左侧,并且希望将第二个 <time> 元素向下移动。

.timeline li > time **{**
    **position:** absolute**;**
    **left:** -3.5em**;**
**}**
.timeline li > time + time **{**
    **top:** 1em**;**
    **left:** -3.85em**;**
**}**

在前面的代码中,第一个选择器选择了我们两个 <time> 标签,而第二个选择器使用之前提到的 time + time 技巧,仅选择了两个 <time> 标签中的第二个。

通过使用 em 单位而不是 px(像素)单位来进行位置偏移,我们使得偏移相对于当前的字体大小,而不管它具体是多少。这使我们能够在不需要重新调整任何像素位置的情况下自由更改字体大小。

位置偏移的具体值可能需要根据特定的字体样式进行调整,但一般来说,我们使用负的 left 位置将内容向左移动,超出正常位置,使用正的 top 位置将内容向页面下方移动。

在将日期移到竖线的左侧后,我们还需要将主要内容稍微向右移动,以免它与竖线挤在一起。padding-left 属性可以解决这个问题。同时,在调整左侧内边距时,我们还可以在底部添加一点内边距,以便将每个播放项分开。

.timeline li **{**
    **padding-left:** 1em**;**
    **padding-bottom:** 1em**;**
**}**

由于日期和主要内容已经位于竖线的两侧,我们不再需要在日期后面添加标点符号,因此我们将移除为最后一个 <time> 元素添加冒号的样式。

.timeline li > time:last-of-type:after **{**
    **content:** ": "**;**
**}**

我们能够进行这一更改突出了使用 CSS 添加冒号的其中一个原因。如果我们在标记中显式包含了标点符号(例如,通过 JavaScript 代码生成它),那么我们的标记就会与样式紧密耦合。如果样式修改导致冒号不再合适,我们还需要返回修改 JavaScript。然而,使用我们当前方法的好处在于,样式和标记之间更加独立。任何样式的更改都局限于我们的 CSS 规则,不需要修改 JavaScript。

为了进一步改善视觉样式,我们可以对时间轴进行一些其他更改。我们可以增加每部戏剧标题的字体大小,以使这些信息更加突出。同时,我们还可以在标题下方增加一些额外的空白,并稍微缩进描述列表。

.timeline li > cite **{**
    **font-size:** 1.5em**;**
    **line-height:** 1em**;**
    **padding-bottom:** 0.5em**;**
**}**
.timeline dl **{**
    **padding-left:** 1.5em**;**
**}**

最后,为了更加完善,让我们在垂直线上添加一个项目符号,用来标记每个戏剧,并将标题与日期更紧密地联系起来。我们使用一个大号项目符号(比正常大小大好几倍),并将其定位在直线上方。

.timeline li > time:first-of-type:after **{**
    **content:** "\2022"**;**
    **font-size:** 3em**;**
    **line-height:** 0.4em**;**
    **position:** absolute**;**
    **right:** -0.65em**;**
    **top:** 0.1em**;**
**}**

如你所见,Unicode 字符表示项目符号可以表示为 "\2022"。确切的位置值将取决于特定的字体,但通过一点试验和调整,能够完善这些调整。

现在,我们的时间轴开始看起来像一个实际的时间轴(如图 5-6 所示)。在你的页面中,你可以包含额外的样式来定义字体、颜色等,但即使没有这些装饰,可视化效果依然有效。

额外的样式澄清了时间轴元素的结构。图 5-6. 额外的样式澄清了时间轴元素的结构。

第 8 步:添加交互性

莎士比亚的 40 部戏剧的完整细节对于首次查看我们的时间轴来说可能会显得有些压倒性。如果最初只显示戏剧标题,用户通过交互揭示更多细节,效果会更好。因为我们是在自己构建这个可视化,所以我们拥有所有必要的控制权来实现这一目标。

首先,我们将设置一些额外的样式。使用 CSS 隐藏播放详情有多种方法,其中最明显的是使用 display:none 属性。不过,正如我们稍后将看到的,对于我们的时间轴,设置 max-height0 会是一个更好的选择。如果元素的最大高度为 0,那么理论上它应该是不可见的。在实践中,我们还必须将 overflow 属性设置为 hidden。否则,即使 <dl> 元素本身没有高度,浏览器仍然会显示溢出的内容。由于我们希望我们的描述列表初始时是隐藏的,因此可以将该属性设置为默认值。

.timeline li dl **{**
    **max-height:** 0**;**
    **overflow:** hidden**;**
**}**

要显示戏剧的详情,用户点击 <cite> 元素中的戏剧标题。为了提示用户可以点击标题,我们将鼠标光标从默认的箭头改为“可点击”的手形光标。我们还可以将 display 属性从默认的 inline 改为 block。这个变化使得用户点击的区域更大且更一致。

.timeline li > cite **{**
    **cursor:** pointer**;**
    **display:** block**;**
**}**

最后,我们需要一种方式来显示戏剧的详情。我们通过向该戏剧的 <li> 元素添加一个 "expanded" 类来实现。当这个类存在时,我们的样式应该覆盖 max-height 为 0 的设置。

.timeline li.expanded dl **{**
    **max-height:** 40em**;**
**}**

展开后的 max-height 的具体值将取决于内容。不过,通常来说,它应该足够大,以显示该项目的完整细节。当然,可以适当加大一点以防万一。但请注意,不要过度加大,使其不合理地变得非常大。(我们将在这一步的最后解释为什么。)

有了这些样式后,我们可以添加一点 JavaScript 来控制它们。这并不会花费太多时间。第一步是编写一个事件处理程序,当用户点击时会被调用。

**var** clicked = **function**(ev) {
    **if** (ev.target.nodeName === "CITE") {
        *// Code continues...*
    }
};

这个函数接受一个单一的参数,具体来说是一个 Event 对象,包含关于点击的详细信息。其中一个细节是 .target 属性,它将包含对页面上用户点击的特定元素的引用。我们只关心点击 <cite> 元素。

一旦我们知道某个 <cite> 被点击,我们就找到它的父元素 <li>。然后我们可以检查 <li> 是否有 "expanded" 类。如果没有,我们添加它。如果该类已经存在,我们移除它。

**var** clicked = **function**(ev) {
    **if** (ev.target.nodeName === "CITE") {
        **var** li = ev.target.parentNode;
        **if** (li.className === "expanded") {
            li.className = "";
        } **else** {
            li.className = "expanded";
        }
    }
};

我们的方法有点原始,因为它只允许为 <li> 定义一个类。但对于本例来说,这已经足够了,因此我们将继续使用这个方法。

注意

现代浏览器拥有更为复杂的接口来控制元素的类属性。这个接口就是classList,它轻松支持每个元素多个类,并且能够通过一个函数轻松地切换类的开关。然而,旧版浏览器(即 Ie9 及更早版本)不支持这个接口。由于我们不需要额外的功能,旧版的className对于本例来说已经足够了。

在定义了我们的事件处理函数后,我们可以将它与时间线上的任何点击关联起来。标准的 addEventListener 方法会为任何元素创建这个关联。

document.getElementById("timeline").addEventListener("click", clicked);

你可能会好奇,为什么我们将事件监听器关联到整个时间线的可视化,而不是单独为每个 <cite> 添加事件监听器。那种替代方法会消除在处理程序中检查事件目标的需要;然而,事实证明,这比我们现在的做法效率要低得多。事件监听器会消耗相当多的 JavaScript 资源,若我们将它们保持在最低限度,我们的页面性能会更好。

如果你使用的是 jQuery,所需的代码甚至更简单:

$("#timeline").on("click", "cite", **function**() {
    $(**this**).parent("li").toggleClass("expanded");
})

我们几乎准备好向全世界展示我们新改进的时间线了,但还有一个最终的细节可以完善。我们当前的版本会一下子显示或隐藏一个播放项的详情。这种过渡对用户来说可能会很突兀,因为内容会瞬间出现或消失。我们可以通过平滑过渡两种状态来提供更好的用户体验,而对于这个时间线来说,自然的过渡方式是动画效果调整内容的高度。当详情被隐藏时,它们的高度是 0。而当我们想要显示它们时,我们会逐渐将高度动画到其自然值。

使用 JavaScript 也可以添加这些动画。事实上,jQuery 库提供了相当广泛的动画功能。然而,在现代浏览器中,使用 CSS 过渡来动画化内容要好得多。Web 浏览器已经针对 CSS 进行了优化,通常将计算任务卸载到专用的高性能图形协处理器上。在这些情况下,基于 CSS 的动画性能通常比 JavaScript 高出几个数量级。使用 CSS 进行动画的唯一缺点是老旧浏览器不支持。但动画通常对大多数网页来说并不是关键。当然,动画效果很好,但如果使用较旧浏览器的用户错过了流畅的过渡效果,那也不是世界末日。网页仍然会正常运行

CSS 的transition属性是定义 CSS 动画最简单的方式。它指定了要动画化的实际属性、动画的持续时间以及要遵循的缓动函数。这是我们在示例中可以使用的规则:

.timeline li dl **{**
    **transition:** max-height 500ms ease-in-out**;**
**}**

该规则定义了时间轴的<dl>元素的过渡效果。它指定要动画化的属性是max-height,因此每当元素的max-height属性发生变化时(而这正是我们在添加或移除"expanded"类时所修改的属性),过渡效果将生效。过渡规则还指定动画应持续 500 毫秒,并且应“缓慢开始”并“缓慢结束”。这个属性表示动画应当从慢速开始,逐渐加速,然后在结束前再慢下来。通常这种行为比以恒定速度动画化更自然。

CSS 过渡可以动画化许多 CSS 属性,但有一个重要的限制。起始值和结束值必须是明确的。这个限制解释了为什么我们要动画化max-height而不是height,即使实际上我们只是想改变height。不幸的是,我们不能动画化height,因为在描述列表展开时它没有明确的值。每个<dl>的高度都会根据其内容有所不同,且我们无法在 CSS 中预测这些值。另一方面,max-height属性为两种状态提供了明确的值——在这个例子中是040em——因此 CSS 可以动画化其过渡效果。我们只需确保没有任何<dl>的内容超过40em的高度。否则,超出的内容将被截断。然而,这并不意味着我们应该将展开的<dl>元素的max-height设置为一个天文数字。为了理解为什么,请考虑如果我们将max-height过渡到1000em,而某个<dl>只需要10em的高度会发生什么情况。忽略(为了简化)缓动的复杂性,它将只需要完整过渡时间的 1/100 就能显示该元素的全部内容。原本我们计划持续 500 毫秒的动画将在 5 毫秒内结束。

还有一个与 CSS 过渡相关的最后一个复杂问题。大多数浏览器在官方标准最终确定之前就实现了该功能。为了确保它们的实现不会与未来可能的官方标准变更冲突,浏览器供应商最初使用专有语法实现了过渡效果。该语法会在属性名称前添加一个前缀(-webkit- 用于 Safari 和 Chrome,-moz- 用于 Firefox,-o- 用于 Opera)。为了覆盖所有主要浏览器,我们必须为每个前缀包含一个规则。

.timeline li dl **{**
    **-webkit-transition:** max-height 500ms ease-in-out**;**
       **-moz-transition:** max-height 500ms ease-in-out**;**
         **-o-transition:** max-height 500ms ease-in-out**;**
            **transition:** max-height 500ms ease-in-out**;**
**}**

注意

Internet Explorer 不需要前缀,因为微软直到标准稳定后才实现了过渡效果。而且,指定多个前缀没有坏处,因为浏览器会忽略它们不理解的属性。

现在,我们手工制作的时间线能够完美响应用户互动。图 5-7 展示了完整的可视化效果。

一个完全交互式的时间线只需要 HTML、CSS 和一些 JavaScript。图 5-7. 一个完全交互式的时间线只需要 HTML、CSS 和一些 JavaScript。

使用 Web 组件

在这个例子中,我们将查看另一种替代方法;我们将不再使用低级 JavaScript 从头构建时间线,而是集成一个功能齐全的时间线组件:TimelineJS (timeline.knightlab.com/)。在很多方面,这种方法与低级 JavaScript 完全相反。最基本的形式下,它根本不需要编程;它可以像在博客文章中嵌入 YouTube 视频一样简单。然而,这种方法牺牲了对最终可视化效果的许多控制,因此我们也将查看如何重新获得一些控制权。

步骤 1:预览标准组件

在我们花费太多时间定制可视化效果之前,值得先看看组件的最基本形式。幸运的是,TimelineJS 使这个过程变得非常简单。网站 (timeline.knightlab.com/)会详细带你完成步骤,但简单来说,步骤如下:

  1. 创建一个 Google Docs 表格 (docs.google.com/)以提供时间线的数据。

  2. 发布该表格以供网络访问,这将创建一个网址。

  3. 在 TimelineJS 网站的表单中输入该网址,这将生成一个 HTML 代码片段。

  4. 将代码片段复制并粘贴到您的网页中。

图 5-8 展示了莎士比亚剧本的表格是怎样的 (docs.google.com/spreadsheet/ccc?key=0An4ME25ELRdYdDk4WmRacmxjaDM0V0tDTk9vMnQxU1E#gid=0)。

TimelineJS 组件可以从 Google Docs 表格获取数据。图 5-8. TimelineJS 组件可以从 Google Docs 表格获取数据。

TimelineJS 为这个时间轴创建的 HTML 代码片段如下:

**<iframe** src="http://cdn.knightlab.com/libs/timeline/latest/embed/index.html?
    source=0An4ME25ELRdYdDk4WmRacmxjaDM0V0tDTk9vMnQxU1E&font=Bevan-PotanoSans&
    maptype=toner&lang=en&height=650" width="100%" height="650"
frameborder="0"**>**
**</iframe>**

当被包含在页面中时,该代码片段会呈现一个完全互动的时间轴,如 图 5-9 所示。

TimelineJS 在  中构建一个完整的时间轴组件。图 5-9. TimelineJS 在 <iframe> 中构建一个完整的时间轴组件。

如果结果满足你的可视化需求,你可能无需进一步操作。许多使用 TimelineJS 的网页就是以这种方式进行的。然而,最简单的做法也存在一些潜在的问题:

  • 可视化所需的数据必须公开在一个 Google Docs 表格中,因此这种方法可能不适用于机密数据。

  • 数据源是一个电子表格,因此频繁更新或显示更多实时事件可能会变得困难。这个问题实际上并不会影响我们的莎士比亚时间轴,但如果你创建的时间轴显示的是像社交网络上的热门话题这样的实时数据,静态的电子表格就不太实用了。

  • 嵌入的组件样式选项很少。虽然 TimelineJS 提供的默认样式和选项非常吸引人,但它们非常有限,可能不适合你的网页。

  • 时间轴作为 <iframe> 嵌入,这使得 TimelineJS 完全控制页面中该部分显示的内容。虽然绝对没有迹象表明支持 TimelineJS 的组织会这么做,理论上它们可能会以你的网站不希望的方式更改内容(例如,插入广告)。

幸运的是,这些潜在的担忧不会妨碍我们将漂亮的 TimelineJS 可视化效果添加到我们的网页中。TimelineJS 的开发者将所有软件作为开源提供,使我们能够灵活解决上述问题。在本示例的其余部分中,我们将看到如何解决这些问题。

第 2 步:包含所需的组件

要使用 TimelineJS,我们的网页必须包含 CSS 样式表和 JavaScript 代码。目前我们将坚持使用默认样式,因此只需要一个额外的样式表。主要的 JavaScript 代码包含在 timeline.js 中。

虽然不明显,但 TimelineJS 还需要 jQuery。当你嵌入 TimelineJS 的 <iframe> 时,你的主网页不需要包含 jQuery,因为 <iframe> 会包含它。然而,要将时间轴直接集成到页面中,我们必须显式地包含 jQuery。不过,我们可以使用内容分发网络(CDN),而不是自己托管它。(有关内容分发网络的优缺点,请参见 第二章。

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>**
    **<meta** charset="utf-8"**>**
    **<title></title>**
    **<link** rel="stylesheet" type="text/css" href="css/timeline.css"**>**
  **</head>**
  **<body>**
    **<script** src="//cdnjs.cloudflare.com/ajax/libs/jquery/2.0.3/jquery.min.js"**>**
    **</script>**
    **<script** src="js/timeline-min.js"**></script>**
  **</body>**
**</html>**

此时的 HTML 并不包括我们放置时间轴的元素。该元素有一些特殊的约束,我们将在将其添加到页面时考虑这些约束。

第 3 步:准备数据

因为 TimelineJS 支持我们在前面的示例中没有使用的一些功能,我们将向数据集添加一些额外的属性。不过,整体格式看起来与之前相同:

**var** plays = [
  {
    "play": "The Two Gentlemen of Verona",
    "genre": "Comedies",
    "date": "1589-1591",
    "record": "Francis Meres'...",
    "published": "First Folio (1623)",
    "performance": "adaptation by Benjamin Victor...",
    "evidence": "The play contains..."
  }, {
    "play": "The Taming of the Shrew",
    "genre": "Comedies",
    "date": "1590-1594",
    "record": "possible version...",
    "published": "possible version...",
    "performance": "According to Philip Henslowe...",
    "evidence": "Kier Elam posits..."
  }, {
    "play": "Henry VI, Part 2",
    "genre": "Histories",
    "date": "1590-1591",
    "record": "version of the...",
    "published": "version of the...",
    "performance": "although it is known...",
    "evidence": "It is known..."
    "media": "http://upload.wikimedia.org/wikipedia/commons/9/96/
FirstFolioHenryVI2.jpg",
    "credit": "Wikimedia Commons",
    "caption": "Photo of the first page..."
  *// Data set continues...*
},

如你所见,我们已向戏剧中添加了类别信息、可选的多媒体链接以及用于字幕和说明的文本。以此为起点,我们可以重新排列数据,以匹配 TimelineJS 的预期格式。接下来显示的对象的基本结构包括一些总体属性(如标题),以及一个事件数组。我们可以将其初始化为空数组。

**var** timelineData = {
    headline: "Chronology of Shakespeare's Plays",
    type: "default",
    date: []
};

请注意,type 属性是必需的,应该设置为 "default"

现在,我们遍历数据集,将事件添加到 timelineData 中。对于以下代码,我们将使用 forEach 进行此遍历,但这里有很多替代方法可以使用(包括 for 循环、数组的 .map() 方法,或 jQuery 的 $.each()$.map() 函数)。

plays.forEach(**function**(play) {
    **var** start = play.date;
    **var** end = "";
    **if** (play.date.indexOf("-") !== -1) {
        **var** dates = play.date.split("-");
        start = dates[0];
        end = dates[1];
    }
});

每次迭代的第一步是解析日期信息。它可以有两种形式:单一年份("1591")或年份范围("1589-1591")。我们的代码假设为单一日期,并在找到两个日期时进行调整。

现在我们可以通过将新对象推送到 timelineData.date 数组中,提供事件的完整条目,格式为 TimelineJS 格式。

timelineData.date.push({
   startDate: start,
   endDate: end,
   headline: play.play,
   text: play.evidence,
   tag: play.genre,
   asset: {
       media: play.media,
       credit: play.credit,
       caption: play.caption
   }
});

第 4 步:创建默认时间轴

在设置好 HTML 和准备好数据集后,我们现在可以调用 TimelineJS 来创建其默认的可视化效果。然而,弄清楚如何做到这一点并不像查看文档那么简单。这是因为 TimelineJS 假设它主要作为嵌入式和独立组件使用,而不是作为页面的一部分。因此,文档描述了如何使用 storyjs_embed.js 封装器来嵌入内容。该封装器加载所有 TimelineJS 资源(样式表、JavaScript、字体等),如果我们按照预期使用它,我们将遇到大部分与简单嵌入 <iframe> 相同的问题。

幸运的是,跳过所有嵌入内容,直接访问 JavaScript 代码并不困难。这仅需要三步:

  1. 设置配置对象。

  2. 创建一个 TimelineJS 对象。

  3. 使用配置对象初始化该对象。

这些步骤在我们的 JavaScript 中将会是这样的,暂时省略了细节:

**var** timelineConfig = {*/* Needs properties */*};
**var** timelinejs = **new** VMM.Timeline(*/* Needs parameters */*);
timelinejs.init(timelineConfig);

我们仍然需要填写完整的配置对象以及VMM.Timeline构造函数的参数。配置对象在 TimelineJS 源码中有文档说明(* github.com/NUKnightLab/TimelineJS#config-options *)。我们必须提供一个type(等于"timeline")、尺寸、数据源以及将要放置时间轴的 HTML 元素的id。例如,我们可以使用如下内容:

**var** timelineConfig = {
       type:       "timeline",
       width:      "100%",
       height:     "600",
       source:     {timeline: timelineData},
       embed_id:   "timeline"
};

我们需要将这些相同的参数传递给构造函数。特别地,我们提供容器的id和尺寸。

**var** timelinejs = **new** VMM.Timeline("timeline","100%","600px");

最后,我们必须小心地构建我们的 HTML 标记。TimelineJS 会为嵌入的<iframe>适当设置其 HTML 样式,但这些样式与直接嵌入页面中的时间轴并不完全兼容。特别是,它会绝对定位时间轴并设置其z-index。如果我们不进行补偿,时间轴将会脱离页面内容的流,几乎肯定这是不希望出现的情况。解决这个问题的方法有很多种,我们将在这个例子中使用一种简单的方法。我们不使用单一的<div>,而是使用两个嵌套的<div>元素。内层的<div>将包含时间轴,外层的<div>则为时间轴建立了定位上下文和尺寸。

**<div** style="position:relative;height:600px;"**>**
    **<div** id="timeline"**></div>**
**</div>**

现在,当我们的 JavaScript 执行时,它会生成集成的时间轴,如图 5-10 所示,并使用默认的 TimelineJS 样式。

在我们完成这一步之前,值得考虑一下我们是如何到达这一点的。我们使用了一个完整的 Web 组件,并有明确的使用说明,但我们忽略了这些说明。相反,我们只包含了该组件的一部分(虽然是一个重要部分)。弄清楚如何使用一个孤立的 Web 组件部分(以一种它并不一定意图的方式)可能涉及一些猜测和反复试验,显然这种方法存在一定风险。即使你现在设法让它工作,将来的更新可能会使你的实现失效。如果你在你的网站上采用这种方法,最好彻底测试实现并在更新时格外小心。

只需稍加工作,我们可以在页面中直接嵌入 TimelineJS 时间轴,而无需使用图 5-10. 只需稍加工作,我们可以在页面中直接嵌入 TimelineJS 时间轴,而无需使用<iframe>

第 5 步:调整时间轴样式

现在我们已经解决了<iframe>可能引起的问题,我们可以将注意力转向时间轴的外观。timeline.css样式表决定了这个外观,并且有几种方法可以调整它。

  • ****直接修改 *timeline.css*****。尽管这种方法看起来是最直接的,但它可能不是我们应该采用的方式。如果你查看文件,会发现它是压缩后的 CSS,难以阅读和理解。进行适当的修改将是一个挑战。此外,如果我们以后更新到 TimelineJS 的新版本,新版本可能会包含一个新的 timeline.css 文件,而我们将不得不从头开始。

  • 使用源代码。TimelineJS 使用 LESS (lesscss.org/ ) CSS 预处理器编写样式。如果你习惯使用 CSS 预处理器,你可以修改源代码并构建自己定制版本的 timeline.css。LESS 支持变量和混合宏,使得未来更新更容易适应。许多应用程序都可以将 LESS 编译为 CSS;TimelineJS 使用的是 CodeKit (incident57.com/codekit/ ),它仅适用于苹果的 Mac OS X 系统,并且源代码包括所有适当的应用程序设置。

  • 替代 timeline.css 样式。 与其更改 TimelineJS 样式表,不如保持 原样,并添加具有更高优先级的自定义样式。这个方法利用了层叠样式表中的级联特性。

对于这个例子,我们将使用最后一种方法。我们将识别出需要更改的timeline.css样式,并在我们的样式表中添加新的规则,以使这些规则优先于原有样式。当 CSS 发现多个冲突的规则应用于某个元素时,它会通过考虑规则的特定性以及它们在文档中的顺序来解决冲突。我们可以通过使规则比timeline.css中的规则更具体,或者通过使它们同样具体但放在timeline.css之后来优先应用我们的规则。

首先,我们将处理 TimelineJS 使用的字体。默认字体或可选字体没有问题,但它们可能与我们网页的风格不符。此外,下载额外的字体会减慢页面的性能。找到影响字体的样式的最快方法是查看 TimelineJS 在其网站上提供的可选字体之一。例如,如果你选择“Merriweather & News Cycle”选项,你会看到 TimelineJS 为可视化添加了一个额外的样式表,NewsCycle-Merriweather.css,该样式表定义了这个选项的所有字体规则:

.vco-storyjs **{**
    **font-family:** "News Cycle", sans-serif**;**
**}**

*/* Additional styles... */*

.timeline-tooltip **{**
    **font-family:** "News Cycle", sans-serif
**}**

要使用我们自己的字体,我们只需要复制该文件,并将 "News Cycle""Merriweather" 替换为我们自己的选择——在这种情况下是 Avenir。

.vco-storyjs **{**
    **font-family:** "Avenir","Helvetica Neue",Helvetica,Arial,sans-serif**;**
    **font-weight:** 700**;**
**}**

*/* Additional styles... */*

.timeline-tooltip **{**
    **font-family:** "Avenir", sans-serif**;**
**}**

定制 TimelineJS 可视化的其他方面更具挑战性,但并非不可能。然而,这些定制比较脆弱,因为 TimelineJS 实现的任何细微变化都可能使它们失效。如果你的页面需要这类定制,它是可以实现的。

对于我们的示例,我们将更改 TimelineJS 在可视化底部部分使用的蓝色。它使用这种颜色来突出显示活动项、显示时间轴标记以及事件线。要找到需要覆盖的具体规则,需要通过浏览器的开发者工具进行一些侦查,但这里是如何将颜色从蓝色更改为绿色的方法:

.vco-timeline .vco-navigation .timenav .content .marker.active .flag
.flag-content h3,
.vco-timeline .vco-navigation .timenav .content .marker.active .flag-small
.flag-content h3 **{**
    **color:** green**;**
**}**
.vco-timeline .vco-navigation .timenav-background .timenav-line **{**
    **background-color:** green**;**
**}**
.vco-timeline .vco-navigation .timenav .content .marker .line .event-line,
.vco-timeline .vco-navigation .timenav .content .marker.active .line
.event-line,
.vco-timeline .vco-navigation .timenav .content .marker.active .dot,
.vco-timeline .vco-navigation .timenav .content .marker.active .line **{**
    **background:** green**;**
**}**

将字体变化与替代的配色方案结合,可以帮助可视化效果更好地融入整个网页,如图 5-11 所示。

调整 TimelineJS 的 CSS 可以帮助将其样式与网页的其余部分相匹配。图 5-11。调整 TimelineJS 的 CSS 可以帮助将其样式与网页的其余部分相匹配。

总结

在本章中,我们探讨了多种创建时间轴可视化的方法。最常见的方法依赖于一个开源库,但我们还考虑了另外两种选择。在其中一种方法中,我们从头开始开发了时间轴的代码,这使我们对其外观和行为有完全的控制。对于另一种极端方法,我们研究了一个流行的开源网页组件。通常,页面通过嵌入<iframe>元素来使用该组件,但我们也看到,完全可以将开源代码提取并更加无缝地集成到我们的页面中,甚至在必要时改变视觉样式。

第六章:地理数据可视化

人类在评估数据时渴望获得上下文,因此在上下文可用时提供它是非常重要的。在上一章中,我们看到时间轴可以提供一个参考框架;现在我们将研究另一个同样重要的上下文:地点。如果数据集包含地理坐标或具有与不同地理区域相对应的值,您可以使用基于地图的可视化提供地理上下文。本章中的示例考虑了两种类型的基于地图的可视化。

在前两个示例中,我们希望展示数据如何按区域变化。结果的可视化称为分级图(choropleth maps),通过颜色来突出显示不同区域的不同特征。对于接下来的两个示例,虽然可视化数据本身并不会直接按区域变化,但数据确实有地理成分。通过将数据展示在地图上,我们可以帮助用户理解它。

更具体地,我们将看到以下内容:

  • 如何使用特殊的地图字体通过最少的 JavaScript 创建地图

  • 如何使用 JavaScript 操作可缩放矢量图(SVG)地图

  • 如何使用简单的映射库将地图添加到网页中

  • 如何将功能全面的地图库集成到可视化中

使用地图字体

将地图添加到网页中的一种技巧出乎意料地简单,但常常被忽视——地图字体。这些字体的两个示例是 Stately(intridea.github.io/stately/用于美国和 Continental(contfont.net/)用于欧洲。地图字体是特殊用途的网页字体,它们的字符集包含地图符号,而不是字母和数字。通过几个简单的步骤,我们将使用 Continental 中的符号创建欧洲的可视化。

步骤 1:在页面中包含字体

Stately 和 Continental 的官方网站提供了更详细的字体安装说明,但实际上,只需要包含一个 CSS 样式表。对于 Continental,这个样式表自然叫做continental.css。不需要任何 JavaScript 库。

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>**
    **<meta** charset="utf-8"**>**
    **<title></title>**
    **<link** rel="stylesheet" type="text/css" href="css/continental.css"**>**
  **</head>**
  **<body>**
    **<div** id="map"**></div>**
  **</body>**
**</html>**

注意

对于生产环境的网站,你可能希望将 continental.css 与网站的其他样式表结合使用,以减少浏览器需要发出的网络请求数量。

步骤 2:显示一个国家

要显示一个单一的国家,我们只需在 HTML 中包含一个<span>元素,并设置相应的属性即可。我们可以直接在标记中完成这项操作,添加一个类属性,值为map-,后跟两个字母的国家缩写。(fr 是法国的国际两字母缩写。)

**<div** id="map"**>**
    **<span** class="map-fr"**></span>**
**</div>**

对于这个示例,我们将使用 JavaScript 生成标记。

**var** fr = document.createElement("span");
fr.className = "map-fr";
document.getElementById("map").appendChild(fr);

在这里,我们创建了一个新的<span>元素,给它赋予了类名"map-fr",并将其添加到地图<div>中。

最后一个整理步骤是设置字体大小。默认情况下,任何地图字体字符的大小与普通文本字符相同。对于地图,我们希望字体更大一些,因此可以使用标准 CSS 规则来增大字体大小。

#map **{**
    **font-size:** 200px**;**
**}**

就是这样,添加法国到网页上所需要的全部操作,正如在图 6-1 中所示。

地图字体使得将地图添加到网页变得非常容易。图 6-1. 地图字体使得将地图添加到网页变得非常容易。

第 3 步:将多个国家合并到一个地图中

对于这个例子,我们希望展示不仅仅是一个国家。我们想要基于联合国人口数据(* www.un.org/en/development/desa/population/ *)2010 年的数据,展示所有欧洲国家的中位年龄。为此,我们将创建一个包含所有欧洲国家的地图,并根据数据对每个国家进行样式设置。

这个可视化的第一步是将所有国家放入一个单一的地图中。由于每个国家都是大陆字体中的一个独立字符,我们希望将这些字符叠加在一起,而不是将它们分布到整个页面。这需要设置一些 CSS 规则。

  #map **{**
➊    **position:** relative**;**
  **}**
  #map > [class*="map-"] **{**
➋    **position:** absolute**;**
➌    **top:** 0**;**
      **left:** 0**;**
  **}**

首先,我们将外部容器的定位设置为relative ➊。此规则不会改变外部容器的样式,但它确立了一个定位上下文,供容器内的任何元素使用。这些元素将是我们的单个国家符号,我们将它们的定位设置为absolute ➋。接着,我们分别将每个国家符号定位到地图的顶部和左侧 ➌,使它们重叠在一起。由于我们已将容器定位为relative,因此这些国家符号将相对于该容器进行定位,而不是相对于整个页面。

请注意,我们使用了一些 CSS 技巧,将这些定位应用到该元素中的所有单个符号上。我们首先通过选择idmap的元素来开始。这里没有什么特别的。直接子代选择器(>)则表示后面的内容应该匹配该元素的直接子元素,而不是任意后代元素。最后,属性选择器[class*="map-"]仅指定具有包含map-字符的类的子元素。由于所有的国家符号将是具有类map-xx(其中xx是两位字母的国家缩写)的<span>元素,这将匹配我们所有的国家。

在我们的 JavaScript 中,我们可以从一个列出所有国家的数组开始,并遍历它。对于每个国家,我们创建一个带有相应类的<span>元素,并将其插入到地图的<div>中。

**var** countries = [
  "ad", "al", "at", "ba", "be", "bg", "by", "ch", "cy", "cz",
  "de", "dk", "ee", "es", "fi", "fo", "fr", "ge", "gg", "gr",
  "hr", "hu", "ie", "im", "is", "it", "je", "li", "lt", "lu",
  "lv", "mc", "md", "me", "mk", "mt", "nl", "no", "pl", "pt",
  "ro", "rs", "ru", "se", "si", "sk", "sm", "tr", "ua", "uk",
  "va"
];
**var** map = document.getElementById("map");
countries.forEach(**function**(cc) {
    **var** span = document.createElement("span");
    span.className = "map-" + cc;
    map.appendChild(span);
});

通过定义这些样式规则,我们可以在地图的<div>中插入多个<span>元素,创建出图 6-2 所示的欧洲地图,虽然它有些平淡,但已经是一个完整的地图。

将地图字符叠加在一起可以创建一个完整的地图。图 6-2. 将地图字符叠加在一起可以创建一个完整的地图。

第 4 步:根据数据变化国家符号

现在我们已经准备好创建实际的数据可视化了。自然,我们将从数据开始,在这种情况下是来自联合国的数据。以下是我们如何将这些数据格式化为一个 JavaScript 数组。(完整的数据集可以在本书的源代码中找到,地址是 jsDataV.is/source/。)

**var** ages = [
    { "country": "al", "age": 29.968 },
    { "country": "at", "age": 41.768 },
    { "country": "ba", "age": 39.291 },
    { "country": "be", "age": 41.301 },
    { "country": "bg", "age": 41.731 },
    *// Data set continues...*

我们可以通过几种方式使用这些数据来修改地图。例如,我们可以通过 JavaScript 代码直接设置可视化属性,例如更改每个国家符号的color样式。这样是可行的,但它忽略了地图字体的一大优势。使用地图字体时,我们的可视化是标准的 HTML,因此我们可以使用标准的 CSS 来进行样式设置。如果将来我们想要更改页面上的样式,它们将全部包含在样式表中,而我们无需在 JavaScript 代码中查找并调整颜色。

为了指示哪些样式适用于某个国家的符号,我们可以为每个符号附加一个data-属性。

➊ **var** findCountryIndex = **function**(cc) {
       **for** (**var** idx=0; idx<ages.length; idx++) {
           **if** (ages[idx].country === cc) {
               **return** idx;
           }
       }
       **return** -1;
   }
   **var** map = document.getElementById("map");
   countries.forEach(**function**(cc) {
       **var** idx = findCountryIndex(cc);
       **if** (idx !== -1) {
           **var** span = document.createElement("span");
           span.className = "map-" + cc;
➋         span.setAttribute("data-age", Math.round(ages[idx].age));
           map.appendChild(span);
       }
   });

在这段代码中,我们将data-age属性设置为平均年龄,四舍五入到最接近的整数➋。为了找出某个国家的年龄,我们需要该国在ages数组中的索引。findCountryIndex()函数➊可以以直接的方式完成这一操作。

现在我们可以根据data-age属性来分配 CSS 样式规则。这是为不同年龄段创建简单蓝色渐变的开始,其中较大中位数年龄的颜色为较深的蓝绿色。

#map > [data-age="44"] **{** **color:** #2d9999**;** **}**
#map > [data-age="43"] **{** **color:** #2a9493**;** **}**
#map > [data-age="42"] **{** **color:** #278f8e**;** **}**
*/* CSS rules continue... */*

注意

尽管这些内容超出了本书的范围,但 CSS 预处理器如 LESS(lesscss.org/)和 SASS(sass-lang.com/)使得创建这些规则变得更加简单。

现在我们可以看到图 6-3 中展示的年龄趋势的良好可视化效果。

通过 CSS 规则,我们可以更改单个地图符号的样式。图 6-3. 通过 CSS 规则,我们可以更改单个地图符号的样式。

第 5 步:添加图例

为了完成可视化,我们可以在地图上添加图例。因为地图本身只是标准的 HTML 元素,并且使用了 CSS 样式,所以创建匹配的图例非常简单。这个示例涵盖了一个相对广泛的范围(28 到 44 岁),因此线性渐变作为关键图例效果很好。你自己的实现将取决于你希望支持的具体浏览器版本,但通用的样式规则如下:

#map-legend .key **{**
    **background:** linear-gradient(to bottom, #004a4a 0%,#2d9999 100%)**;**
**}**

在图 6-4 中,结果的可视化清晰简洁地总结了欧洲国家的中位年龄。

标准 HTML 也可以为可视化提供图例。图 6-4. 标准 HTML 也可以为可视化提供图例。

使用可缩放矢量图形

像前一个示例中的地图字体易于使用且视觉效果显著,但只有少数几种地图字体可用,且它们显然无法涵盖所有可想象的地理区域。对于其他区域的可视化,我们需要找到不同的技术。地图,当然,最终是图像,网页浏览器可以显示多种不同的图像格式。特别是,有一种格式叫做可缩放矢量图形(SVG),特别适合交互式可视化。正如我们将在这个示例中看到的,JavaScript 代码(以及 CSS 样式)可以轻松自然地与 SVG 图像进行交互。

尽管本节的示例处理的是地图,但这里的技巧并不限于地图。每当你有一个 SVG 格式的图表或插图时,你都可以直接在网页上操作它。

注意

使用 SVG 时有一个重要的考虑因素:只有现代的网页浏览器支持它。更具体来说,IE8(及更早版本)无法显示 SVG 图像。如果你的网站用户中有大量使用旧版浏览器的用户,你可能需要考虑其他替代方案。

对于网页开发者来说,SVG 特别方便,因为它的语法使用与 HTML 相同的结构。你可以使用许多与 HTML 相同的工具和技巧来处理 SVG。例如,考虑一个基本的 HTML 文档。

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>***<!-- -->***</head>**
  **<body>**
    **<nav>***<!-- -->***</nav>**
    **<main>**
      **<section>***<!-- -->***</section>**
    **</main>**
    **<nav>***<!-- -->***</nav>**
  **</body>**
**</html>**

与下一个示例进行对比:在 SVG 文档中表示的急救通用符号。

注意

如果你曾经使用过 HTML5 之前的 HTML,可能会特别注意到它们的相似性,因为 SVG 的头部文本与 HTML4 遵循相同的格式。

**<?xml** version="1.0" encoding="UTF-8"**?>**
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
    "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
**<svg** id="firstaid" version="1.1" xmlns="http://www.w3.org/2000/svg"
     width="100" height="100"**>**
    **<rect** id="background" x="0" y="0" width="100" height="100" rx="20" **/>**
    **<rect** id="vertical"   x="39" y="19" width="22" height="62" **/>**
    **<rect** id="horizontal" x="19" y="39" width="62" height="22" **/>**
**</svg>**

你甚至可以使用 CSS 对 SVG 元素进行样式设置。下面是我们如何给前面的图像上色:

svg#firstaid **{**
    **stroke:** none**;**
**}**
svg#firstaid #background **{**
    **fill:** #000**;**
**}**
svg#firstaid #vertical,
svg#firstaid #horizontal **{**
    **fill:** #FFF**;**
**}**

图 6-5 展示了 SVG 如何渲染。

SVG 图像可以直接嵌入到网页中。图 6-5. SVG 图像可以直接嵌入到网页中。

HTML 与 SVG 之间的关联实际上远比类似的语法要强。在现代浏览器中,你可以在同一网页中混合使用 SVG 和 HTML。为了演示如何实现这一点,让我们为美国乔治亚州的 159 个县可视化健康数据。数据来自县健康排名(www.countyhealthrankings.org/)。

步骤 1:创建 SVG 地图

我们的可视化从地图开始,因此我们需要一张乔治亚州县的 SVG 格式插图。尽管这看起来可能是一个挑战,但实际上有许多免费的 SVG 地图来源,以及可以为几乎任何区域生成 SVG 地图的专用应用程序。例如,维基共享资源(commons.wikimedia.org/wiki/Main_Page)包含了大量的开源地图,其中包括许多乔治亚州的地图。我们将使用一张显示历史遗址名录数据的地图(commons.wikimedia.org/wiki/File:NRHP_Georgia_Map.svg#file)。

下载地图文件后,我们可以根据需要进行调整,移除图例、颜色和其他不需要的元素。虽然你可以在文本编辑器中完成这项工作(就像编辑 HTML 一样),但你可能会发现使用像 Adobe Illustrator 这样的图形程序,或者像 Sketch(www.bohemiancoding.com/sketch/)这样的更专注于网页的应用程序会更方便。你还可能想利用 SVG 优化网站(petercollingridge.appspot.com/svg-optimiser/)或应用程序(github.com/svg/),这些工具可以通过移除多余的标签并减少图形程序有时过度的精度来压缩 SVG 文件。

我们的结果将是一系列的<path>元素,每个县一个。我们还需要为每个路径分配一个classid,以表示县名。最终生成的 SVG 文件可能如下所示。

**<svg** version="1.1" xmlns="http://www.w3.org/2000/svg"
    width="497" height="558"**>**
    **<path** id="ck" d="M 216.65,131.53 L 216.41,131.53 216.17,131.53..." **/>**
    **<path** id="me" d="M 74.32,234.01 L 74.32,232.09 74.32,231.61..." **/>**
    **<path** id="ms" d="M 64.96,319.22 L 64.72,319.22 64.48,318.98..." **/>**
    *<!-- Markup continues... -->*

总结一下,创建 SVG 地图的步骤如下。

  1. 找到一个合适许可的 SVG 格式地图文件,或使用专用地图应用程序创建一个。

  2. 在图形应用程序中编辑 SVG 文件,以移除多余的组件并简化插图。

  3. 使用优化网站或应用程序优化 SVG 文件。

  4. 在你的常规 HTML 编辑器中进行最后调整(例如添加id属性)。

步骤 2:将地图嵌入页面

将 SVG 地图嵌入网页的最简单方法是直接将 SVG 标记嵌入 HTML 标记中。例如,要包含急救符号,只需在页面本身内包含 SVG 标签,如➊至➋所示。你不需要包含通常存在于独立 SVG 文件中的头标签。

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
     **</head>**
     **<body>**
       **<div>**
➊       **<svg** id="firstaid" version="1.1"
             xmlns="http://www.w3.org/2000/svg"
           width="100" height="100"**>**
           **<rect** id="background" x="0" y="0"
                 width="100" height="100" rx="20" **/>**
           **<rect** id="vertical" x="39" y="19"
                 width="22" height="62" **/>**
           **<rect** id="horizontal" x="19" y="39"
                 width="62" height="22" **/>**
➋       **</svg>**
      **</div>**
     **</body>**
   **</html>**

如果你的地图相对简单,直接嵌入是将其包含在页面中最简单的方法。然而,我们的乔治亚州地图即使经过优化后也有大约 1 MB 的大小。对于分辨率合理的地图,这并不罕见,因为描述复杂的边界,如海岸线或河流,可能会导致大型的<path>元素。特别是当地图不是页面的唯一重点时,你可以通过先加载页面的其余部分来提供更好的用户体验。这样,当地图在后台加载时,用户可以阅读其他内容。如果适合你的站点,你甚至可以添加一个简单的动画进度加载器。

如果你使用的是 jQuery,加载地图只需要一条指令。但你需要确保,在加载完成之前,代码不会开始操作地图。以下是源代码中的示例:

$("#map").load("img/ga.svg", **function**() {
    *// Only manipulate the map inside this block*
})

第 3 步:收集数据

我们的可视化数据可以直接从 County Health Rankings(www.countyhealthrankings.org/)以 Excel 电子表格的形式获得。我们将在前期将其转换为 JavaScript 对象,并为每个县添加一个对应的两字母代码。以下是该数组的起始部分。

**var** counties = [
    {
      "name":"Appling",
      "code":"ap",
      "outcomes_z":0.93,
      "outcomes_rank":148,
      *// Data continues...*
    },
    {
      "name":"Atkinson",
      "code":"at",
      "outcomes_z":0.40,
      "outcomes_rank":118,
    *// Data set continues...*
];

对于这次可视化,我们希望展示各县之间健康结果的差异。数据集提供了两种变量来表示该值,一个是排名,另一个是 z-score(标准分数,衡量样本与均值之间的偏差程度)。County Health Rankings 网站提供了稍微修改过的 z-score,偏离传统统计定义。正常的 z-score 总是正值;然而,在这个数据集中,主观上优于平均水平的测量值会乘以-1,使其变为负值。例如,某个县的健康结果比均值好两个标准差时,其 z-score 为-2,而不是 2。这种调整使得在我们的可视化中使用这些 z-score 变得更加容易。

我们在使用这些 z-score 的第一步是找到最大值和最小值。我们可以通过提取结果作为一个单独的数组,然后使用 JavaScript 的内置Math.max()Math.min()函数来完成这项工作。请注意,以下代码使用了map()方法来提取数组,而该方法仅在现代浏览器中可用。然而,既然我们已经选择使用 SVG 图像,我们的用户已经被限制为使用现代浏览器,因此当有可能时,我们也可以利用这一点。

**var** outcomes = counties.map(**function**(county) {**return** county.outcomes_z;});
**var** maxZ = Math.max.apply(**null**, outcomes);
**var** minZ = Math.min.apply(**null**, outcomes);

请注意,我们在这里使用了.apply()方法。通常,Math.max()Math.min()函数接受以逗号分隔的参数列表。而我们当然有一个数组。apply()方法可以与任何 JavaScript 函数一起使用,将数组转化为逗号分隔的列表。第一个参数是要使用的上下文,在我们的例子中并不重要,因此我们将其设置为null

为了完成数据准备,让我们确保最小值和最大值范围对称于均值。

**if** (Math.abs(minZ) > Math.abs(maxZ)) {
    maxZ = -minZ;
} **else** {
    minZ = -maxZ;
}

例如,如果 z 分数的范围从-21.5,那么这段代码将把范围扩展到[-2, 2]。这个调整还会使颜色比例变得对称,从而使我们的可视化更容易被用户解读。

第 4 步:定义颜色方案

为地图定义一个有效的颜色方案可能相当棘手,但幸运的是,有一些非常好的资源可以参考。对于这次可视化,我们将依赖 Chroma.js 库(driven-by-data.net/about/chromajs/)。这个库包含了许多处理和操作颜色以及颜色比例的工具,可以满足最为高阶的色彩理论学者。对于我们的例子来说,我们可以利用预定义的色阶,特别是由 Cynthia Brewer 最初定义的那些色阶(colorbrewer2.org/)。

Chroma.js 库在流行的内容分发网络上有提供,因此我们可以依赖像 CloudFlare 的 cdnjs 这样的网络来托管它(cdnjs.com/)。

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>**
    **<meta** charset="utf-8"**>**
    **<title></title>**
  **</head>**
  **<body>**
    **<div** id="map"**></div>**
    **<script**
     src="///cdnjs.cloudflare.com/ajax/libs/chroma-js/0.5.2/chroma.min.js"**>**
    **</script>**
  **</body>**
**</html>**

若要使用预定义的色阶,我们将色阶的名称("BrBG"表示 Brewer 的棕色到蓝绿色色阶)传递给chroma.scale()函数。

**var** scale = chroma.scale("BrBG").domain([maxZ, minZ]).out("hex");

同时,我们还会指明我们色阶的范围(minZmaxZ,虽然因为数据集的 z 分数调整,我们需要反转顺序)以及我们期望的输出格式。"hex"输出是常见的"#012345"格式,兼容 CSS 和 HTML 标记。

第 5 步:为地图上色

颜色方案确定后,我们可以将适当的颜色应用到地图上的每个县。那大概是整个可视化过程中最简单的步骤。我们遍历所有县,通过它们的id值找到相应的<path>元素,并通过设置fill属性来应用颜色。

counties.forEach(**function**(county) {
    document.getElementById(county.code)
      .setAttribute("fill", scale(county.outcomes_z));
})

结果地图,展示在图 6-6 中,显示了哪些县在 2014 年健康结果上高于平均水平,哪些则低于平均水平。

CSS 规则可以为 SVG 插图中的每个 SVG 元素设置样式。图 6-6. CSS 规则可以为 SVG 插图中的每个 SVG 元素设置样式。

第 6 步:添加图例

为了帮助用户解读地图,我们可以向可视化中添加图例。我们可以利用 Chroma.js 的色阶轻松创建一个表格,来解释变化。对于这个表格,我们将使用四个增量来表示均值两侧的颜色。这样我们总共有九种颜色用于图例。

**<table** id="legend"**>**
    **<tr** class="scale"**>**
        **<td></td><td></td><td></td><td></td><td></td>**
        **<td></td><td></td><td></td><td></td>**
    **</tr>**
    **<tr** class="text"**>**
        **<td** colspan="4"**>**Worse than Average**</td>**
        **<td>**Average**</td>**
        **<td** colspan="4"**>**Better than Average**</td>**
    **</tr>**
**</table>**

一些简单的 CSS 样式将使表格呈现得恰当。由于我们有九种颜色,我们将每个表格单元格的宽度设置为11.1111%(1/9 约等于 0.111111)。

table#legend tr.scale td **{**
    **height:** 1em**;**
    **width:** 11.1111%**;**
**}**
table#legend tr.text td:first-child **{**
    **text-align:** left**;**
**}**
table#legend tr.text td:nth-child(2) **{**
    **text-align:** center**;**
**}**
table#legend tr.text td:last-child **{**
    **text-align:** right**;**
**}**

最后,我们使用之前创建的 Chroma 色标来设置图例表格单元格的背景颜色。因为图例是一个<table>元素,所以我们可以直接访问行和行中的单元格。尽管在以下代码中,这些元素看起来像数组,但它们并不是真正的 JavaScript 数组,因此不支持像forEach()这样的数组方法。目前,我们将使用for循环来迭代它们,但如果你更喜欢使用数组方法,敬请期待一个简单的技巧。请注意,由于数据集的 z-score 调整,我们再次是在逆向操作。

   **var** legend = document.getElementById("legend");
   **var** cells = legend.rows[0].cells;
   **for** (**var** idx=0; idx<cells.length; idx++) {
       **var** td = cells[idx];
➊     td.style.backgroundColor = scale(maxZ -
           ((idx + 0.5) / cells.length) * (maxZ - minZ));
   };

在 ➊ 处,我们计算当前索引占图例颜色总数的比例((idx + 0.5) / cells.length),将其乘以色标的总范围(maxZ - minZ),并从最大值中减去结果。

结果是地图的图例,如图 6-7 所示。

一个 HTML  可以作为图例。图 6-7. 一个 HTML <table> 可以作为图例。

第 7 步:添加交互

为了完成可视化,我们让用户可以在地图上将鼠标悬停在一个县上,以查看更多详细信息。当然,平板或智能手机用户无法进行鼠标交互。为了支持这些用户,你可以为触摸或点击事件添加类似的交互。那段代码几乎与下一个示例相同。

我们将从定义一个表格开始,以显示县的详细信息。

**<table** id="details"**>**
    **<tr><td>**County:**</td><td></td></tr>**
    **<tr><td>**Rank:**</td><td></td></tr>**
    **<tr><td>**Health Behaviors:**</td><td></td></tr>**
    **<tr><td>**Clinical Care:**</td><td></td></tr>**
    **<tr><td>**Social & Economic Factors:**</td><td></td></tr>**
    **<tr><td>**Physical Environment:**</td><td></td></tr>**
**</table>**

最初,我们不希望这个表格可见。

table#details **{**
    **display:** none**;**
**}**

为了显示表格,我们使用事件处理函数来跟踪鼠标何时进入或离开县的 SVG 路径。为了找到这些<path>元素,我们可以使用现代浏览器支持的querySelectorAll()函数。不幸的是,这个函数并不会返回一个真正的元素数组,因此我们不能使用forEach()等数组方法来迭代这些元素。然而,有一个技巧可以将返回的列表转换为一个真正的数组。

[].slice.call(document.querySelectorAll("#map path"))
    .forEach(**function**(path) {
        path.addEventListener("mouseenter", **function**(){
            document.getElementById("details").style.display = "table";
        });
        path.addEventListener("mouseleave", **function**(){
            document.getElementById("details").style.display = "none";
        });
    }
);

这段代码调用了[].slice.call()函数,并将“几乎是数组”的对象作为参数传递。结果是一个真正的数组,包含了所有有用的方法。

除了让详细信息表格可见之外,我们还希望用适当的信息更新它。为了帮助显示这些信息,我们可以编写一个函数,将 z-score 转换为更易于理解的解释。以下示例中的具体值是任意的,因为我们在这次可视化中并不追求统计精度。

**var** zToText = **function**(z) {
    z = +z;
    **if** (z > 0.25)  { **return** "Far Below Average"; }
    **if** (z >  0.1)  { **return** "Below Average"; }
    **if** (z > -0.1)  { **return** "Average"; }
    **if** (z > -0.25) { **return** "Above Average"; }
    **return** "Far Above Average";
}

这个函数中有几个值得注意的地方。首先,语句z = +z将 z-score 从字符串转换为数值,以便进行后续的测试。其次,记住由于 z-score 调整,负的 z-score 实际上好于平均值,而正的值则低于平均值。

我们可以使用这个函数为我们的详细信息表格提供数据。第一步是找到与 <path> 元素相关联的完整数据集。为此,我们会在 counties 数组中查找 code 属性与路径的 id 属性匹配的项。

**var** county = **null**;
counties.some(**function**(c) {
    **if** (c.code === **this**.id) {
        county = c;
        **return** **true**;
    }
    **return** **false**;
});

由于 indexOf() 不能让我们按键查找对象,因此我们使用了 some() 方法。该方法在找到匹配项后会立即终止,因此我们避免了遍历整个数组。

一旦我们找到了县的数据,更新表格就是一个简单的过程。以下代码直接更新相关表格单元格的文本内容。为了实现更健壮的功能,你可以为单元格提供类名,并基于这些类名进行更新。

**var** table = document.getElementById("details");
table.rows[0].cells[1].textContent =
    county.name;
table.rows[1].cells[1].textContent =
    county.outcomes_rank + " out of " + counties.length;
table.rows[2].cells[1].textContent =
    zToText(county.health_behaviors_z);
table.rows[3].cells[1].textContent =
    zToText(county.clinical_care_z);
table.rows[4].cells[1].textContent =
    zToText(county.social_and_economic_factors_z);
table.rows[5].cells[1].textContent =
    zToText(county.physical_environment_z);

现在我们只需要进行一些进一步的完善:

   path.addEventListener("mouseleave", **function**(){
       *// Previous code*
➊     **this**.setAttribute("stroke", "#444444");
   });
   path.addEventListener("mouseleave", **function**(){
       *// Previous code*
➋     **this**.setAttribute("stroke", "none");
   });

在这里,我们为高亮显示的县添加了一个边框颜色,位于➊。当鼠标离开路径时,我们移除了位于➋的边框。

到这里为止,我们的可视化示例已经完成。图 6-8 展示了结果。

浏览器(以及一些代码)可以将 SVG 插图转化为互动式可视化。图 6-8. 浏览器(以及一些代码)可以将 SVG 插图转化为互动式可视化。

包括地图作为背景

到目前为止,我们在本章中看过的地图可视化主要关注地理区域——比如欧洲的国家或乔治亚州的县。在这些情况下,分级色块地图在展示区域间差异时非常有效。然而,并非所有地图可视化都具有相同的焦点。在某些情况下,我们希望将地图更多地作为可视化数据的上下文或背景。

当我们想要将地图作为可视化背景时,我们可能会发现传统的地图绘制库比自定义的分级色块地图更适合我们的需求。最著名的地图库可能是 Google Maps (maps.google.com/),你几乎肯定在网页上见过许多嵌入的 Google 地图。然而,Google Maps 也有几个免费的开源替代品。对于这个示例,我们将使用 Stamen Design 提供的 Modest Maps 库 (github.com/modestmaps/modestmaps-js/)。为了展示这个库,我们将可视化美国的主要 UFO 目击事件 (en.wikipedia.org/wiki/UFO_sightings_in_the_United_States),至少是那些足够重要以至于值得在维基百科上出现的事件。

第一步:设置网页

对于我们的可视化,我们将依赖于 Modest Maps 库中的几个组件:核心库本身和可以在库的示例文件夹中找到的 spotlight 扩展。在生产环境中,您可能会将这些组件合并并压缩结果以优化性能,但在我们的示例中,我们将它们单独包含。

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
     **</head>**
     **<body>**
➊     **<div** id="map"**></div>**
       **<script** src="js/modestmaps.js"**></script>**
       **<script** src="js/spotlight.js"**></script>**
     **</body>**
   **</html>**

我们还在 ➊ 处设置了一个 <div> 来容纳地图。不出所料,它的 id"map"

第 2 步:准备数据

维基百科数据可以格式化为 JavaScript 对象数组。我们可以在对象中包含任何信息,但我们肯定需要观察数据的纬度和经度,以便将其定位到地图上。这是您可能如何构建数据的示例。

**var** ufos = [
{
    "date": "April, 1941",
    "city": "Cape Girardeau",
    "state": "Missouri",
    "location": [37.309167, -89.546389],
    "url": "http://en.wikipedia.org/wiki/Cape_Girardeau_UFO_crash"
},{
    "date": "February 24, 1942",
    "city": "Los Angeles",
    "state": "California",
    "location": [34.05, -118.25],
    "url": "http://en.wikipedia.org/wiki/Battle_of_Los_Angeles"
},{
*// Data set continues...*

location 属性保存纬度和经度(负值表示西方)作为一个包含两个元素的数组。

第 3 步:选择地图样式

和大多数地图库一样,Modest Maps 使用图层构建地图。图层的构建过程与在 Photoshop 或 Sketch 等图形应用程序中的工作方式非常相似。后续的图层会向地图添加更多的视觉信息。在大多数情况下,地图的基础图层由图像瓷砖组成。像标记或路线等附加图层可以叠加在图像瓷砖之上。

当我们告诉 Modest Maps 创建地图时,它会计算所需的瓷砖(包括大小和位置),然后异步地从互联网上请求这些瓷砖。瓷砖定义了地图的视觉样式。Stamen Design 本身发布了几套瓷砖,您可以在maps.stamen.com/上查看。

要使用 Stamen 瓷砖,我们将向页面中添加一个小的 JavaScript 库。该库可以直接从 Stamen Design 获取 (maps.stamen.com/js/tile.stamen.js)。它应在 Modest Maps 库之后被包含。

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>**
    **<meta** charset="utf-8"**>**
    **<title></title>**
  **</head>**
  **<body>**
    **<div** id="map"**></div>**
    **<script** src="js/modestmaps.js"**></script>**
    **<script** src="js/spotlight.js"**></script>**
    **<script** src="http://maps.stamen.com/js/tile.stamen.js"**></script>**
  **</body>**
**</html>**

对于我们的示例,"toner" 样式非常合适,因此我们将使用这些瓷砖。要使用这些瓷砖,我们为地图创建一个 瓷砖图层

**var** tiles = **new** MM.StamenTileLayer("toner");

在考虑图像瓷砖来源时,请注意任何版权限制。一些图像瓷砖必须获得授权,甚至那些自由提供的瓷砖,通常也要求用户标明提供者为来源。

第 4 步:绘制地图

现在我们准备好绘制地图了。这需要两条 JavaScript 语句:

**var** map = **new** MM.Map("map", tiles);
map.setCenterZoom(**new** MM.Location(38.840278, -96.611389), 4);

首先我们创建一个新的 MM.Map 对象,给它传入包含地图的元素的 id 和我们刚刚初始化的瓷砖。然后,我们提供地图中心的纬度和经度以及初始缩放级别。对于您自己的地图,您可能需要做些实验来找到合适的值,但对于本示例,我们将地图居中并缩放,以便舒适地显示美国本土。

结果地图,如图 6-9 所示,为显示观察数据提供了基础。

地图库可以根据地理坐标显示地图。图 6-9。地图库可以根据地理坐标显示地图。

请注意,Stamen Design 和 OpenStreetMap 都有注明出处。这是 Stamen Design 许可协议要求的归属。

第 5 步:添加目击事件

当我们的地图搭建完成后,是时候添加单独的 UFO 目击事件了。我们使用聚光灯扩展来突出显示这些地点,因此我们首先为地图创建一个聚光灯图层。我们还需要设置聚光灯效果的半径。与中心和缩放参数一样,这里也需要通过一定的反复试验来调整。

**var** layer = **new** SpotlightLayer();
layer.spotlight.radius = 15;
map.addLayer(layer);

现在,我们可以遍历构成我们数据的所有目击事件。对于每一个目击事件,我们提取该地点的纬度和经度,并将该地点添加到聚光灯图层中。

ufos.forEach(**function**(ufo) {
    layer.addLocation(**new** MM.Location(ufo.location[0], ufo.location[1]));
});

到此为止,我们的可视化已经完成。图 6-10 展示了在美国上空据称出现 UFO 的地点,并在合适的神秘背景下呈现。

在地图库中添加图层可以突出显示地图区域。图 6-10。在地图库中添加图层可以突出显示地图区域。

集成一个功能齐全的地图库

前一个例子中的 Modest Maps 库是一个很好的简单地图可视化库,但它没有 Google Maps 这样全面功能的支持。不过,有一个开源库提供了这些功能:Leaflet (leafletjs.com/)。在这个例子中,我们将构建一个更复杂的可视化,使用基于 Leaflet 的地图。

在 20 世纪 40 年代,两个私人铁路公司在美国东南部的客运市场展开竞争。两个竞争最直接的路线是“银彗星号”(由海滨航空公司运营)和“南方号”(由南方铁路公司运营)。这两条线路都服务于从纽约到阿拉巴马州伯明翰的乘客。有人认为“南方号”最终成功的原因之一是其路线较短,旅程较快,使得南方铁路公司在竞争中占据了优势。让我们通过可视化来展示这一优势。

第 1 步:准备数据

我们可视化的数据作为两条路线的时刻表随时可用。更精确的比较可能会考虑同一年份的时刻表,但在这个示例中,我们将使用 1941 年《南方人号》的时刻表(* www.streamlinerschedules.com/concourse/track1/southerner194112.html )和 1947 年《银彗星号》的时刻表( www.streamlinerschedules.com/concourse/track1/silvercomet194706.html *),因为它们可以在互联网上轻松获取。时刻表只包含车站名称,因此我们需要查找所有车站的纬度和经度值(例如使用 Google 地图),以便将它们标记在地图上。我们还可以计算站点之间的时间差(以分钟为单位)。这些计算会得到两个数组,每个数组对应一列车。

**var** seaboard = [
    { "stop": "Washington",
      "latitude": 38.895111, "longitude": -77.036667,
      "duration": 77 },
    { "stop": "Fredericksburg",
      "latitude": 38.301806, "longitude": -77.470833,
      "duration": 89 },
    { "stop": "Richmond",
      "latitude": 37.533333, "longitude": -77.466667,
      "duration": 29 },
    *// Data set continues...*
];
**var** southern = [
    { "stop": "Washington",
      "latitude": 38.895111, "longitude": -77.036667,
      "duration": 14 },
    { "stop": "Alexandria",
      "latitude": 38.804722, "longitude": -77.047222,
      "duration": 116 },
    { "stop": "Charlottesville",
      "latitude": 38.0299, "longitude": -78.479,
      "duration": 77 },
    *// Data set continues...*
];

第 2 步:设置网页和库

要将 Leaflet 地图添加到我们的网页中,我们需要包含该库及其附带的样式表。两者都可以通过内容分发网络获取,因此无需将其托管在我们自己的服务器上。

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
       **<link** rel="stylesheet"
        href="http://cdn.leafletjs.com/leaflet-0.7.2/leaflet.css" **/>**
       **</head>**
       **<body>**
➊       **<div** id="map"**></div>**
       **<script**
         src="http://cdn.leafletjs.com/leaflet-0.7.2/leaflet.js"**>**
       **</script>**
     **</body>**
   **</html>**

当我们创建页面时,我们还会在➊处定义一个用于地图的<div>容器。

第 3 步:绘制基础地图

《银彗星号》和《南方人号》列车在纽约和伯明翰之间往返(对于《南方人号》,甚至一路行驶到新奥尔良)。但是对于我们的可视化,相关的区域位于华盛顿特区和乔治亚州亚特兰大之间,因为只有在这个区域,列车路线有所不同;在其余行程中,路线基本相同。因此,我们的地图将从西南部的亚特兰大延伸到东北部的华盛顿特区。通过一些试验和错误,我们可以确定地图的最佳中心点和缩放级别。中心点定义了地图中心的纬度和经度,而缩放级别决定了地图初次显示时所覆盖的区域。当我们创建地图对象时,我们会将包含元素的id以及这些参数传递给它。

**var** map = L.map("map",{
    center: [36.3, -80.2],
    zoom: 6
});

对于这个特定的可视化,缩放或平移地图没有太大意义,因此我们可以加入额外的选项来禁用这些交互功能。

   **var** map = L.map("map",{
       center: [36.3, -80.2],
➊     maxBounds: [ [33.32134852669881, -85.20996093749999],
➋                [39.16414104768742, -75.9814453125] ],
       zoom: 6,
➌     minZoom: 6,
➍     maxZoom: 6,
➎     dragging: **false**,
➏     zoomControl: **false**,
➐     touchZoom: **false**,
       scrollWheelZoom: **false**,
       doubleClickZoom: **false**,
➑     boxZoom: **false**,
➒     keyboard: **false**
   });

设置最小缩放级别➌和最大缩放级别➍为与初始缩放级别相同将禁用缩放功能。我们还在➏禁用了屏幕上的地图缩放控件。其他缩放控件也同样被禁用(➐到➑)。对于平移,我们在➎禁用了拖动地图,在➒禁用了键盘方向键。我们还指定了地图的纬度/经度范围(➊和➋)。

由于我们已经禁用了用户平移和缩放地图的功能,我们还应该确保鼠标指针在地图上悬停时不会误导用户。leaflet.css 样式表期望启用缩放和拖动,因此它将鼠标指针设置为“抓取”手形图标。我们可以用我们自己的样式规则覆盖这个设置。我们必须在引入 leaflet.css 文件后定义这个规则。

.leaflet-container **{**
    **cursor:** default**;**
**}**

与 Modest Maps 示例一样,我们将地图基于一组瓦片。支持 Leaflet 的瓦片提供商有很多;其中一些是开源的,而其他则是商业的。Leaflet 提供了一个演示页面 (leaflet-extras.github.io/leaflet-providers/preview/),你可以用它来比较一些开源的瓦片提供商。在我们的示例中,我们希望避免包含道路的瓦片,因为在 1940 年代,高速公路网络与现在有很大不同。Esri 提供了一个中性化的 WorldGrayCanvas 瓦片集,这对于我们的可视化非常合适。它包含了当前的县界,而一些县的边界可能自 1940 年代以来发生了变化。不过,在我们的示例中,我们不需要担心这个细节,但在任何生产环境的可视化中,你可能需要考虑这一点。Leaflet 的 API 允许我们在一个语句中创建瓦片图层并将其添加到地图上。Leaflet 内置了一个选项来处理版权归属,这样我们可以确保正确地标注瓦片来源。

   L.tileLayer("http://server.arcgisonline.com/ArcGIS/rest/services/"+
               "Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}", {
        attribution: "Tiles &copy; Esri &mdash; Esri, DeLorme, NAVTEQ",
➊      maxZoom: 16
   }).addTo(map);

请注意,➊ 处的 maxZoom 选项表示该特定瓦片集可用的最大缩放层级。这个值独立于我们允许地图缩放的层级。

有了地图和基础瓦片图层,我们就有了一个很好的可视化起点(见 图 6-11)。

基础图层地图为可视化提供了画布。图 6-11。基础图层地图为可视化提供了画布。

第 4 步:将路线添加到地图

在可视化的下一步中,我们想要在地图上显示两条路线。首先,我们将简单地在地图上绘制每条路线。然后,我们会添加一个动画,实时显示两条路线,同时展示哪一条更快。

Leaflet 库中有一个函数,正是我们需要的来绘制每条路线:polyline() 连接一系列由其端点的纬度和经度定义的线条,并为地图做准备。我们的数据集包括每条路线停靠站的地理坐标,因此我们可以使用 JavaScript 的 map() 方法来格式化这些值以供 Leaflet 使用。对于 Silver Comet 示例,以下语句提取了它的停靠站。

seaboard.map(**function**(stop) {
    **return** [stop.latitude, stop.longitude]
})

这条语句返回一组纬度/经度对:

[
  [38.895111,-77.036667],
  [38.301806,-77.470833],
  [37.533333,-77.466667],
  [37.21295,-77.400417],
  */* Data set continues... */*
]

该结果是polyline()函数的完美输入。我们将为每条路线使用它。选项允许我们为线路指定颜色,我们将其与该时代相关铁路的官方颜色相匹配。我们还通过将clickable选项设置为false来表明,点击时这些线路没有任何功能。

L.polyline(
    seaboard.map(**function**(stop) {**return** [stop.latitude, stop.longitude]}),
    {color: "#88020B", weight: 1, clickable: **false**}
).addTo(map);

L.polyline(
    southern.map(**function**(stop) {**return** [stop.latitude, stop.longitude]}),
    {color: "#106634", weight: 1, clickable: **false**}
).addTo(map);

添加这个功能后,图 6-12 中显示的可视化开始传达两条路线的相对距离。

附加地图图层将数据添加到画布中。 图 6-12. 附加地图图层将数据添加到画布中。

第 5 步:添加动画控制

接下来,我们将对两条路线进行动画处理。这不仅会强调较短路线的竞争优势,还会让可视化更加有趣和吸引人。我们肯定希望让用户能够启动和停止动画,因此我们的地图将需要一个控制按钮。Leaflet 库本身没有动画控制器,但它提供了大量的自定义支持。这部分支持就是一个通用的Control对象。我们可以通过从该对象开始并扩展它来创建一个动画控制器。

L.Control.Animate = L.Control.extend({
    *// Custom code goes here*
});

接下来,我们定义自定义控制器的选项。这些选项包括它在地图上的位置、其状态的文本和工具提示(标题),以及在动画开始或停止时调用的函数。我们将这些定义在一个options对象中,如下所示,这样 Leaflet 就可以将其集成到其正常功能中。

L.Control.Animate = L.Control.extend({
    options: {
        position: "topleft",
        animateStartText: "![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/dtvis-js/img/common-01.png.jpg)",
        animateStartTitle: "Start Animation",
        animatePauseText: "![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/dtvis-js/img/common-02.png.jpg)",
        animatePauseTitle: "Pause Animation",
        animateResumeText: "![](https://github.com/OpenDocCN/greenhat-zh/raw/master/docs/dtvis-js/img/common-01.png.jpg)",
        animateResumeTitle: "Resume Animation",
        animateStartFn: **null**,
        animateStopFn: **null**
    },

对于我们的示例,我们使用 UTF-8 字符作为播放和暂停控制。在生产环境的可视化中,你可能会考虑使用图标字体或图像,以便最大程度地控制外观。

我们的动画控制还需要一个onAdd()方法,供 Leaflet 在将控制添加到地图时调用。该方法构建控制的 HTML 标记,并将其返回给调用者。

   onAdd: **function** () {
       **var** animateName = "leaflet-control-animate",
➊         container = L.DomUtil.create(
               "div", animateName + " leaflet-bar"),
           options = **this**.options;

➋         **this**._button = **this**._createButton(
               **this**.options.animateStartText,
               **this**.options.animateStartTitle,
               animateName,
               container,
           **this**._clicked);

       **return** container;
   },

我们对onAdd()的实现分两步构建标记。首先,从➊开始,创建一个<div>元素,并为该元素赋予两个类:leaflet-control-animateleaflet-bar。第一个类是我们动画控制器特有的,我们可以使用它为我们的控制器应用唯一的 CSS 规则。第二个类是 Leaflet 为所有工具栏定义的通用类。通过将它添加到动画控制器中,我们使该控制器与其他 Leaflet 控制器保持一致。请注意,Leaflet 在➊处包含了L.DomUtil.create()方法,用来处理创建元素的细节。

onAdd()的第二部分在这个<div>容器内创建一个按钮元素。大部分工作是在➋处的_createButton()函数中进行的,我们稍后会详细查看。传递给该函数的参数包括:

  • 按钮的文本

  • 当鼠标悬停在按钮上时显示的工具提示(标题)

  • 应用到按钮的 CSS 类

  • 插入按钮的容器

  • 按钮点击时调用的函数

如果你在想为什么这个函数的名字以下划线(_)开头,那是因为 Leaflet 使用这种命名约定来表示私有方法(和属性)。虽然没有强制要求遵循,但这样做会使熟悉 Leaflet 的人更容易理解我们的代码。

_createButton() 方法本身依赖于 Leaflet 的工具函数。

   _createButton: **function** (html, title, className, container, callback) {
➊     **var** link = L.DomUtil.create("a", className, container);
       link.innerHTML = html;
       link.href = "#";
➋     link.title = title;

       L.DomEvent
➌         .on(link, "mousedown dblclick", L.DomEvent.stopPropagation)
➍         .on(link, "click", L.DomEvent.stop)
➎         .on(link, "click", callback, **this**);

       **return** link;
   },

首先,它将按钮创建为一个 <a> 元素,设置指定的文本、标题和类,并将该元素放入合适的容器中(➊ 到 ➋)。然后,它将几个事件绑定到这个 <a> 元素上。首先,它忽略初始的 mousedown 和双击事件(➌)。它还会防止单击事件在文档树中传播并实现默认行为(➍)。最后,它在 click 事件时执行回调函数(➎)。

回调函数本身是我们接下来的任务。

➊  _running: **false**,

   _clicked: **function**() {
➋     **if** (**this**._running) {
           **if** (**this**.options.animateStopFn) {
               **this**.options.animateStopFn();
           }
           **this**._button.innerHTML = **this**.options.animateResumeText;
           **this**._button.title = **this**.options.animateResumeTitle;
       } **else** {
           **if** (**this**.options.animateStartFn) {
               **this**.options.animateStartFn();
           }
           **this**._button.innerHTML = **this**.options.animatePauseText;
           **this**._button.title = **this**.options.animatePauseTitle;
       }
       **this**._running = !**this**._running;
   },

在我们进入函数之前,我们添加了一个状态变量(_running)来跟踪动画当前是否正在运行。它初始时停止,状态为 ➊。然后我们的回调函数会在 ➋ 处检查这个变量。如果 _runningtrue,意味着动画正在运行并刚被当前点击暂停,因此它会改变控件,指示点击将恢复动画。如果动画没有运行,回调函数会做相反的操作:改变控件,指示后续的点击将暂停它。在这两种情况下,如果有可用的控件函数,回调函数会执行相应的控制函数。最后,它会将 _running 的状态设置为其补集。

我们自定义控件的最后部分添加了一个 reset() 方法,用来清除动画。这个函数将控件恢复到初始状态。

    reset: **function**() {
        **this**._running = **false**;
        **this**._button.innerHTML = **this**.options.animateStartText;
        **this**._button.title = **this**.options.animateStartTitle;
    }
});

为了将我们的自定义控件完全集成到 Leaflet 架构中,我们在 L.control 对象中添加了一个函数。按照 Leaflet 的约定,这个函数的名字以小写字母开头,但除此之外,它的名字与我们的控件名称完全相同。

L.control.animate = **function** (options) {
    **return** **new** L.Control.Animate(options);
};

定义这个最后的函数让我们能够使用通用的 Leaflet 语法创建控件。

L.control.animate().addTo(map);

这是我们之前在图层和折线中看到的相同语法。

第六步:准备动画

在设置好方便的用户控件后,我们现在可以开始处理动画本身。尽管这个特定的动画并不特别复杂,但我们仍然可以遵循最佳实践,尽可能提前计算。由于我们要为两条路线制作动画,我们将定义一个函数,用来为任何输入的路线构建动画。第二个参数将指定折线的选项。这个函数会返回一个按分钟索引的折线路径数组。你可以看到这个函数的基本结构如下。

**var** buildAnimation = **function**(route, options) {
    **var** animation = [];

    *// Code to build the polylines*

    **return** animation;
}

数组中的第一个元素将是路线的第一分钟的折线。我们将在 animation 变量中构建整个数组。

为了构建路径,我们遍历路线上的所有站点。

➊ **for** (**var** stopIdx=0, prevStops=[];
            stopIdx < route.length-1; stopIdx++) {
       *// Code to calculate steps between current stop and next stop*
   }

我们希望跟踪我们已经经过的所有站点,因此我们在➊处定义了 prevStops 数组并将其初始化为空。每次迭代都会计算当前站点到下一个站点的动画步骤。由于不需要超出路线的最后一个站点,所以我们在倒数第二个站点处终止循环(stopIdx < route.length-1;)。

当我们开始计算从当前站点开始的路径时,我们会将该站点及下一个站点存储在局部变量中,并将当前站点添加到跟踪前一个站点的 prevStops 数组中。

**var** stop = route[stopIdx];
**var** nextStop = route[stopIdx+1]
prevStops.push([stop.latitude, stop.longitude]);

对于数据集中的每一个站点,duration 属性存储到下一个站点的分钟数。我们将使用下面的内部循环,从 1 计数到该值。

**for** (**var** minutes = 1; minutes <= stop.duration; minutes++) {
    **var** position = [
        stop.latitude +
          (nextStop.latitude - stop.latitude) *
          (minutes/stop.duration),
        stop.longitude +
          (nextStop.longitude - stop.longitude) *
          (minutes/stop.duration)
    ];
    animation.push(
        L.polyline(prevStops.concat([position]), options)
    );
}

在循环内,我们使用简单的线性插值来计算相应时间的当前位置。这个位置在添加到 prevStops 数组时,形成了该时间的折线路径。这段代码基于路径创建折线,并将其添加到动画数组中。

当我们使用数组 concat() 方法时,我们将位置数组嵌套在另一个数组对象中。这样可以避免 concat() 在附加之前将位置数组展平。你可以在以下示例中看到这种区别。我们想要的是后者的结果。

[[1,2], [3,4]].concat([5,6]);   *// => [[1,2], [3,4], 5, 6]*
[[1,2], [3,4]].concat([[5,6]]); *// => [[1,2], [3,4], [5,6]]*

第 7 步:动画路线

现在终于到了执行动画的时刻。为了初始化它,我们创建一个数组来保存两个路线。

**var** routeAnimations = [
    buildAnimation(seaboard,
      {clickable: **false**, color: "#88020B", weight: 8, opacity: 1.0}
    ),
    buildAnimation(southern,
      {clickable: **false**, color: "#106634", weight: 8, opacity: 1.0}
    )
];

接下来我们计算最大的动画步骤数。这是两个动画数组长度的最小值。

**var** maxSteps = Math.min.apply(**null**,
    routeAnimations.map(**function**(animation) {
        **return** animation.length
    })
);

这条语句看起来可能有些复杂,用来找到最小长度,但它适用于任意数量的路线。如果未来我们决定在地图上增加第三条路线,我们就不需要修改代码。理解这条语句的最好方法是从中间开始,向外推理。以下代码片段将路线动画数组转换为长度数组,具体为 [870,775]

routeAnimations.map(**function**(animation) {**return** animation.length})

要在数组中找到最小值,我们可以使用 Math.min() 函数,除了这个函数期望的参数是一个逗号分隔的参数列表,而不是数组。apply() 方法(任何 JavaScript 函数都可以使用)将数组转换为逗号分隔的参数列表。它的第一个参数是函数的上下文,在我们这里无关紧要,所以我们传递 null 作为该参数。

动画使用 step 变量来跟踪当前状态,我们将其初始化为 0

**var** step = 0;

animateStep() 函数处理动画中的每一个步骤。这个函数有四个部分。

**var** animateStep = **function**() {
    *// Draw the next step in the animation*
}

首先我们检查这是否是动画中的第一步。

   **if** (step > 0) {
       routeAnimations.forEach(**function**(animation) {
➊         map.removeLayer(animation[step-1]);
       });
   }

如果不是,step 将大于零,我们可以在➊处从地图上移除上一个步骤的折线。

接下来我们检查是否已经到达动画的末尾。如果是,则我们从步骤 0 重新启动动画。

**if** (step === maxSteps) {
    step = 0;
}

对于第三部分,我们将当前步骤的折线添加到地图上。

routeAnimations.forEach(**function**(animation) {
    map.addLayer(animation[step]);
});

最后,如果我们已经到达动画的末尾,则返回true

**return** ++step === maxSteps;

我们将在 JavaScript 间隔中反复执行这个步骤函数,如下所示。

   **var** interval = **null**;
   **var** animate = **function**() {
       interval = window.setInterval(**function**() {
➊         **if** (animateStep()) {
               window.clearInterval(interval);
               control.reset();
           }
       }, 30);
   }
➋ **var** pause = **function**() {
       window.clearInterval(interval);
   }

我们使用一个变量来保持对该间隔的引用,并添加函数以开始和停止它。在animate()函数中,我们在➊处检查animateStep()的返回值。当它返回true时,动画完成,因此我们清除该间隔并重置控制。(我们稍后会看到该控制的定义位置。)在➋处的pause()函数停止该间隔。

现在,我们只需要使用第 5 步中创建的对象来定义动画控制。

**var** control = L.control.animate({
    animateStartFn: animate,
    animateStopFn:  pause
});
control.addTo(map);

一旦我们将其添加到地图中,用户将能够激活动画。

第 8 步:为车站创建标签

在完成动画之前,我们将为每个火车站添加一些标签。为了强调时间的流逝,我们将在动画到达相应站点时显示每个标签。为此,我们将使用一个特殊的对象来创建标签;然后我们会创建一个方法来将标签添加到地图上;最后,为了完成标签对象,我们将添加方法来获取或设置标签的状态。

由于 Leaflet 没有预定义的标签对象,我们可以再次创建我们自己的自定义对象。我们从基本的 Leaflet Class开始。

L.Label = L.Class.extend({
    *// Implement the Label object*
});

我们的Label对象接受其在地图上的位置、标签文本和任何选项作为参数。接下来,我们扩展 Leaflet Classinitialize()方法以处理这些参数。

   initialize: **function**(latLng, label, options) {
       **this**._latlng = latLng;
       **this**._label = label;
➊     L.Util.setOptions(**this**, options);
➋     **this**._status = "hidden";
   },

对于位置和文本,我们只是保存它们的值以供稍后使用。对于选项,我们使用 Leaflet 工具在➊处轻松支持默认值。该对象包括一个变量,用于跟踪其状态。最初所有标签都是隐藏的,因此this._status在➋处被适当初始化。

接下来,我们使用options属性定义默认的选项值。

    options: {
        offset: **new** L.Point(0, 0)
    },
});

我们为标签所需的唯一选项是标准位置的偏移量。默认情况下,该偏移量在 x 和 y 坐标中都为0

options属性与initialize方法中对L.Util.setOptions的调用结合,建立了一个默认值(0,0)作为偏移量,当创建Label对象时,可以轻松覆盖该默认值。

接下来,我们编写将标签添加到地图上的方法。

   onAdd: **function**(map) {
➊     **this**._container = L.DomUtil.create("div", "leaflet-label");
➋     **this**._container.style.lineHeight = "0";
➌     **this**._container.style.opacity = "0";
➍     map.getPanes().markerPane.appendChild(**this**._container);
➎     **this**._container.innerHTML = **this**._label;
➏     **var** position = map.latLngToLayerPoint(**this**._latlng);
➐     position = **new** L.Point(
           position.x + **this**.options.offset.x,
           position.y + **this**.options.offset.y
➑     );
➒     L.DomUtil.setPosition(**this**._container, position);
   },

该方法执行以下操作:

  1. 在➊处创建一个新的<div>元素,并应用 CSS 类leaflet-label

  2. 将该元素的line-height设置为0,以解决 Leaflet 计算位置时的一个奇异现象,在➋处

  3. 将元素的opacity设置为0,以匹配其初始hidden状态,在➌处

  4. 将新元素添加到地图的markerPane图层中,在➍处

  5. 将该元素的内容设置为标签文本,在➎处

  6. 使用其定义的纬度/经度在➏处计算标签的位置,然后调整任何偏移量(➐至➑)

  7. 将元素定位到地图上,在➒处

步骤 2——将 line-height 设置为 0——解决了 Leaflet 在地图上定位元素时所使用方法中的一个问题。特别是,Leaflet 并未考虑到同一父容器中的其他元素。通过将所有元素的行高设置为 0,我们可以消除这种影响,从而使计算出的定位是正确的。

最后,我们添加方法来获取和设置标签的状态。如以下代码所示,我们的标签可以具有三种不同的状态值,这些值决定了标签的透明度。

getStatus: **function**() {
    **return** **this**._status;
},
setStatus: **function**(status) {
    **switch** (status) {
        **case** "hidden":
            **this**._status = "hidden";
            **this**._container.style.opacity = "0";
            **break**;
        **case** "shown":
            **this**._status = "shown";
            **this**._container.style.opacity = "1";
            **break**;
        **case** "dimmed":
            **this**._status = "dimmed";
            **this**._container.style.opacity = "0.5";
            **break**;
    }
}

我们添加了调整标签位置的选项,因为并非所有标签都能恰好位于车站的经纬度位置。大多数标签需要稍微移动,以避免与路线折线、底图上的文字或其他标签相互干扰。对于像这个示例这样的自定义可视化,没有什么能替代反复试验的调整。我们通过在数据集中添加另一个 offset 字段来捕捉每个标签的这些调整。增强后的数据集可能如下所示:

**var** seaboard = [
{ "stop": "Washington",     "offset": [-30,-10], */* Data continues... */* },
{ "stop": "Fredericksburg", "offset": [  6,  4], */* Data continues... */* },
{ "stop": "Richmond",       "offset": [  6,  4], */* Data continues... */* },
*// Data set continues...*

步骤 9:构建标签动画

为了创建标签动画,我们可以再次遍历火车的路线。由于我们有不止一条路线,通用的函数可以帮助我们避免代码重复。如以下代码所示,我们没有使用固定数量的参数传递给函数。相反,我们让调用者传入任意数量的单独路线。这些输入参数将存储在 arguments 对象中。

arguments 对象看起来很像 JavaScript 数组。它有一个 length 属性,我们可以使用例如 arguments[0] 来访问单个元素。不幸的是,该对象并不是真正的数组,因此我们无法在其上使用便利的数组方法(如 forEach)。作为解决方法,我们在 buildLabelAnimation() 函数中的第一行代码,使用一个简单的技巧将 arguments 对象转换为真正的 args 数组。

   **var** buildLabelAnimation = **function**() {
➊     **var** args = Array.prototype.slice.call(arguments),
           labels = [];

       *// Calculate label animation values*

       **return** labels;
   }

这段代码稍显冗长,但位于 ➊ 的语句有效地在 arguments 上执行了 slice() 方法。该操作将 arguments 克隆成一个真正的数组。

注意

这个相同的技巧几乎适用于所有 JavaScript 的“类数组”对象。你通常可以使用它将这些对象转换为真正的数组。

由于路线已转换为数组,我们可以使用 forEach 遍历它们,无论它们有多少条。

args.forEach(**function**(route) {
    **var** minutes = 0;
    route.forEach(**function**(stop,idx) {
        *// Process each stop on the route*
    });
});

当我们开始处理每条路线时,我们将 minutes 的值设置为 0。然后,我们可以再次使用 forEach 遍历该路线上的所有站点。

   route.forEach(**function**(stop,idx) {
       **if** (idx !== 0 && idx < route.length-1) {
➊         **var** label = **new** L.Label(
               [stop.latitude, stop.longitude],
               stop.stop,
               {offset: **new** L.Point(stop.offset[0], stop.offset[1])}
           );
           map.addLayer(label);
➋         labels.push(
               {minutes: minutes, label: label, status: "shown"}
           );
➌         labels.push(
               {minutes: minutes+50, label: label, status: "dimmed"}
           );
       }
       minutes += stop.duration;
   });

对于路线中的每个站点,我们首先检查该站点是否是第一个或最后一个。如果是,我们就不想为该站点制作标签动画。否则,我们在➊位置创建一个新的Label对象,并将其添加到地图上。然后,我们将这个Label对象添加到正在积累标签动画数据的labels数组中。注意,我们将每个标签添加到这个数组两次。第一次添加(➋)是在动画到达该站点时;在这种情况下,我们添加它时状态为shown。我们还将在 50 分钟后再次将标签添加到数组(➌),这时它的状态为dimmed。当我们执行动画时,标签会在路线第一次到达车站时显示,然后在稍后变得稍微暗淡。

一旦我们遍历完所有路线,我们的labels数组将指示每个标签何时应该改变状态。然而,此时标签的顺序并不是按照它们动画状态变化的顺序排列的。为了解决这个问题,我们按时间递增的顺序对数组进行排序。

labels.sort(**function**(a,b) {**return** a.minutes - b.minutes;})

要使用我们的新函数,我们调用并传入所有需要动画化的路线。

**var** labels = buildLabelAnimation(seaboard, southern);

因为我们没有对任何路线的起点(华盛顿,DC)或终点(亚特兰大)进行动画处理,所以可以从一开始就在地图上显示它们。我们可以从任何路线中获取坐标;以下示例使用了seaboard数据集。

**var** start = seaboard[0];
**var** label = **new** L.Label(
    [start.latitude, start.longitude],
    start.stop,
    {offset: **new** L.Point(start.offset[0], start.offset[1])}
);
map.addLayer(label);
label.setStatus("shown");

**var** finish = seaboard[seaboard.length-1];
label = **new** L.Label(
    [finish.latitude, finish.longitude],
    finish.stop,
    {offset: **new** L.Point(finish.offset[0], finish.offset[1])}
);
map.addLayer(label);
label.setStatus("shown");

第 10 步:在动画步骤中加入标签动画

现在标签动画数据已经可用,我们可以对我们的动画函数进行一些调整,以便同时包括标签和折线路径。第一个变化是决定何时结束动画。因为我们是在路线经过站点后才逐渐淡化标签,所以不能在所有路径绘制完成后就简单地停止动画。这可能会导致某些标签没有被淡化。我们需要单独的变量来存储每个动画的步骤数,动画的总步骤数将是其中较大的那个。

**var** maxPathSteps = Math.min.apply(**null**,
    routeAnimations.map(**function**(animation) {
        **return** animation.length
    })
);
**var** maxLabelSteps = labels[labels.length-1].minutes;
**var** maxSteps = Math.max(maxPathSteps, maxLabelSteps);

我们还需要一个标签动画数据的副本,以便在动画过程中销毁它,同时保持原始数据不变。我们不希望破坏原始数据,这样用户如果愿意的话,还可以重新播放动画。复制 JavaScript 数组的最简单方法是调用它的slice(0)方法。

注意

我们不能仅仅通过赋值语句(var labelAnimation = labels)来复制数组。在 JavaScript 中,这个语句会将labelAnimation设置为引用与labels相同的实际数组。对第一个数组的任何更改都会影响到后者。

**var** labelAnimation = labels.slice(0);

动画步骤函数本身需要一些额外的代码来处理标签。它现在有五个主要部分;接下来我们将逐一讲解每个部分。我们的第一个调整是确保代码只在我们仍在往地图上添加路径时才移除先前的折线路径。这只有在step小于maxPathSteps时才成立。

**if** (step > 0 && step < maxPathSteps) {
    routeAnimations.forEach(**function**(animation) {
        map.removeLayer(animation[step-1]);
    });
}

下一块代码处理用户重新播放动画的情况。

   **if** (step === maxSteps) {
➊     routeAnimations.forEach(**function**(animation) {
           map.removeLayer(animation[maxPathSteps-1]);
➋     });
➌     labelAnimation = labels.slice(0);
➍     labelAnimation.forEach(**function**(label) {
           label.label.setStatus("hidden");
➎     });
➏     step = 0;
   }

当动画重新播放时,step值仍然会保持为上次动画中的maxSteps。为了重置动画,我们移除每条路线的最后一条折线路径(➊到➋),复制一份标签动画数据(➌),并隐藏所有标签(➍到➎)。我们还将step变量重置为0(➏)。

第三个代码块是一个全新的块,它负责动画化标签。

**while** (labelAnimation.length && step === labelAnimation[0].minutes) {
    **var** label = labelAnimation[0].label;
    **if** (step < maxPathSteps || label.getStatus() === "shown") {
        label.setStatus(labelAnimation[0].status);
    }
    labelAnimation.shift();
}

这个代码块查看labelAnimation数组中的第一个元素(如果存在)。如果该元素的时间值(它的minutes属性)与当前动画步伐相同,我们检查是否需要处理它。我们总是在添加路径时处理标签动画。如果路径已经完成,我们只处理那些已经显示的标签的动画。一旦我们完成了labelAnimation数组中的第一个元素,就会将其从数组中移除(使用shift()方法)并再次检查。我们必须继续检查,以防多个标签动画操作同时安排。

上述代码解释了我们在标签动画准备过程中做的几件事。首先,由于我们已经对标签动画进行了排序,因此只需要查看该数组中的第一个元素。这比在整个数组中查找更高效。其次,由于我们处理的是标签动画数组的副本,而非原始数组,因此一旦完成处理,可以安全地移除元素。

现在我们已经处理了所有标签动画,可以回到折线路径。如果仍然有路径需要动画,我们会像之前一样将其添加到地图上。

**if** (step < maxPathSteps) {
    routeAnimations.forEach(**function**(animation) {
        map.addLayer(animation[step]);
    });
}

在动画步骤函数中的最后一个代码块与之前相同。我们返回一个指示动画是否完成的标志。

**return** ++step === maxSteps;

我们可以对动画进行另一个改进,这次是通过巧妙地使用 CSS。因为我们使用opacity属性来改变标签的状态,我们可以为该属性定义一个 CSS 过渡效果,使任何变化都不那么突兀。

.leaflet-label **{**
   **-webkit-transition:** opacity .5s ease-in-out**;**
      **-moz-transition:** opacity .5s ease-in-out**;**
       **-ms-transition:** opacity .5s ease-in-out**;**
        **-o-transition:** opacity .5s ease-in-out**;**
           **transition:** opacity .5s ease-in-out**;**
**}**

为了兼容所有主流浏览器,我们使用了适当的厂商前缀,但规则的效果是一致的。每当浏览器改变leaflet-label类中元素的透明度时,它都会在 500 毫秒的时间内逐渐过渡。这种过渡效果可以防止标签动画过于分散用户的注意力,从而影响作为可视化主要效果的路径动画。

第 11 步:添加标题

为了完成可视化,我们只需要一个标题和一点解释。我们可以像构建动画控制一样,构建一个标题作为 Leaflet 控制项。实现这个的代码非常简单。

L.Control.Title = L.Control.extend({
     options: {
➊       position: "topleft"
     },

➋    initialize: **function** (title, options) {
           L.setOptions(**this**, options);
           **this**._title = title;
     },

     onAdd: **function** (map) {
         **var** container = L.DomUtil.create("div", "leaflet-control-title");
➌       container.innerHTML = **this**._title;
         **return** container;
     }
 });

 L.control.title = **function**(title, options) {
     **return** **new** L.Control.Title(title, options);
 };

我们在地图的左上角提供了一个默认位置(➊),并接受一个标题字符串作为初始化参数(➋)。在➌时,我们将标题字符串设置为控制项的innerHTML,当我们将其添加到地图时。

现在,我们可以使用以下代码来创建一个带有所需内容的标题对象,并立即将其添加到地图中。这里是一个简单的实现;图 6-13 包含了一些额外的信息。

L.control.title("Geography as a Competitive Advantage").addTo(map);

要设置标题的外观,我们可以为 leaflet-control-title 类的子元素定义 CSS 规则。

此时,我们已经在图 6-13 中实现了两条火车路线的交互式可视化。用户可以清楚地看到,南方路线从华盛顿到亚特兰大的时间更短。

通过地图库在浏览器中构建的地图可以利用交互性来增加兴趣。图 6-13. 通过地图库在浏览器中构建的地图可以利用交互性来增加兴趣。

小结

在本章中,我们看了几种基于地图的可视化方法。在前两个例子中,地理区域是可视化的主要对象,我们构建了等值线地图来对比和对照这些区域。地图字体非常快捷和方便,但仅限于可视化所需的区域存在的情况下。虽然通常需要更多的努力,但如果使用 SVG 来创建自定义地图,我们对地图区域有更大的控制权。与其他图像格式不同,SVG 可以通过简单的 CSS 和 JavaScript 在网页中轻松操作。本章还展示了基于传统地图绘制库的例子。当数据集包含经纬度值时,地图绘制库特别方便,因为它们处理了将这些点定位到二维投影所需的复杂数学计算。正如我们所看到的,一些库相对简单,但仍完全能够绘制数据集。像 Leaflet 这样的功能全面的库提供了更多的功能和自定义选项,我们依赖这些扩展性来创建一个自定义的动态地图。

第七章:使用 D3.js 创建自定义可视化

在本书中,我们讨论了许多为特定类型可视化设计的 JavaScript 库。如果你需要某种特定类型的可视化,而恰好有一个库可以创建它,使用该库通常是创建可视化的最快且最简便的方法。然而,这些库也有缺点。它们都对可视化的外观和行为做出假设,尽管它们提供了一些配置选项,你对结果并没有完全的控制权。有时候,这种权衡是不可接受的。

在这一章中,我们将探索一种完全不同的 JavaScript 可视化方法,这种方法允许我们发挥创意并保持对结果的完全控制。如你所料,这种方法并不像例如添加一个图表库并将数据输入其中那样简单。幸运的是,有一个非常强大的 JavaScript 库可以帮助我们:D3.js(* d3js.org/ *)。D3.js 不提供预定义的可视化,如图表、图形或地图。相反,它是一个数据可视化工具箱,提供创建 你自己的 图表、图形、地图等的工具。

为了展示 D3.js 的一些强大功能,我们将进行一个快速的概览。本章的示例包括以下内容:

  • 为特定数据调整传统图表类型

  • 构建一个响应用户交互的力导向图

  • 使用高质量的 SVG 显示基于地图的数据

  • 创建完全自定义的可视化

调整传统图表类型

D3.js 与其他 JavaScript 库的最大区别在于它的哲学理念。D3.js 不是用来创建预定义的图表和可视化工具的工具,而是一个帮助你创建任何可视化(包括自定义和独特展示)的库。使用 D3.js 创建标准图表需要更多的工作,但通过它,我们不受限于标准图表。为了了解 D3.js 是如何工作的,我们可以创建一个使用典型图表库无法实现的自定义图表。

在这个示例中,我们将可视化现代物理学中最重要的发现之一——哈勃定律。根据该定律,宇宙正在膨胀,因此我们感知到远处星系的运动速度会根据它们与我们的距离而变化。更准确地说,哈勃定律提出,这种速度变化(称为 红移速度)是距离的线性函数。为了可视化这个定律,我们可以将多个星系的速度变化(即 红移速度)与距离进行对比。如果哈勃定律正确,那么图表应该呈现出一条直线。对于我们的数据,我们将使用哈勃 1929 年原始论文中的星系和星系团数据(* www.pnas.org/content/15/3/168.full *),但更新为当前的距离和红移速度值。

到目前为止,这个任务看起来很适合用散点图展示。距离可以作为 x 轴,速度作为 y 轴。不过,有一点不同:物理学家其实并不知道我们要绘制的距离或速度,至少不是准确的数值。他们最多能做的是估算这些值,并且这两个值都可能存在误差。但这并不是放弃这个努力的理由。事实上,这些潜在的误差可能是我们在可视化中需要强调的一个重要方面。为此,我们不会将每个值作为一个点来绘制,而是将它们显示为一个框,框的尺寸将对应该值的潜在误差。虽然这种方法在散点图中不常见,但 D3.js 可以轻松实现。

第 1 步:准备数据

这是根据最新估算得出的图表数据。

表 7-1. 星云和星系团的距离与红移速度

星云/星系团 距离(Mpc) 红移速度(km/s)
NGC 6822 0.500±0.010 57±2
NGC 221 0.763±0.024 200±6
NGC 598 0.835±0.105 179±3
NGC 4736 4.900±0.400 308±1
NGC 5457 6.400±0.500 241±2
NGC 4258 7.000±0.500 448±3
NGC 5194 7.100±1.200 463±3
NGC 4826 7.400±0.610 408±4
NGC 3627 11.000±1.500 727±3
NGC 7331 12.200±1.000 816±1
NGC 4486 16.400±0.500 1307±7
NGC 4649 16.800±1.200 1117±6
NGC 4472 17.100±1.200 729±2

我们可以使用以下数组在 JavaScript 中表示这些数据。

hubble_data = 
    { nebulae: "NGC 6822", distance:  0.500, distance_error: 0.010,
      velocity:   57, velocity_error: 2, },
    { nebulae: "NGC  221", distance:  0.763, distance_error: 0.024,
      velocity:  200, velocity_error: 6, },
    { nebulae: "NGC  598", distance:  0.835, distance_error: 0.105,
      velocity:  179, velocity_error: 3, },
    *// Data set continues...*

第 2 步:设置网页

D3.js 不依赖其他任何库,而且它在大多数 CDN 上都可以获取。我们所需要做的就是将其引入页面。

   <!DOCTYPE html>
   **<html** lang="en"**>**
     **<head>**
       **<meta** charset="utf-8"**>**
       **<title></title>**
     **</head>**
     **<body>**
➊     **<div** id="container"**></div>**
➋     **<script**
         src="//cdnjs.cloudflare.com/ajax/libs/d3/3.4.6/d3.min.js"**>**
       **</script>**
     **</body>**
   **</html>**

我们在➋位置引入 D3.js,并在➊位置设置一个id "container"<div>,用来容纳我们的可视化内容。

第 3 步:为可视化创建一个舞台

与更高层次的库不同,D3.js 并不会自动在页面上绘制可视化图表。我们需要自己动手做这件事。作为回报,我们获得了自由选择绘图技术的权利。我们可以像本书中大多数库那样使用 HTML5 的 <canvas> 元素,或者直接使用原生 HTML。不过,既然我们已经在[第六章看到过它的应用,使用 SVG 似乎是最适合我们图表的方法。因此,我们图表的根元素将是一个 <svg> 元素,我们需要将它添加到页面中。我们可以同时通过属性定义它的尺寸。

如果我们使用 jQuery,可能会做如下操作:

**var** svg = $("<svg>").attr("height", height).attr("width", width);
$("#container").append(svg);

使用 D3.js 时,我们的代码非常相似:

**var** svg = d3.select("#container").append("svg")
    .attr("height", height)
    .attr("width", width);

在这条语句中,我们选择了容器,向其中添加了一个<svg>元素,并设置了该<svg>元素的属性。这条语句突出了 D3.js 与 jQuery 之间一个重要的区别,这个区别常常让刚开始学习 D3.js 的开发者感到困惑。在 jQuery 中,append()方法返回原始选择,这样你就可以继续对该选择进行操作。更具体地说,$("#container").append(svg)返回的是$("#container")

另一方面,在 D3.js 中,append()返回的是一个不同的选择,即新添加的元素。因此,d3.select("#container").append("svg")并不返回容器选择,而是返回新<svg>元素的选择。随后调用的attr()方法因此应用于<svg>元素,而不是"#container"

第 4 步:控制图表的维度

到目前为止,我们还没有指定图表的实际高度和宽度;我们只使用了heightwidth变量。将这些维度存储在变量中会非常有用,并且它使得在可视化中加入边距变得更加容易。下面的代码设置了这些维度;它的形式是 D3.js 可视化中常见的惯例。

**var** margin = {top: 20, right: 20, bottom: 30, left: 40},
    width = 640 - margin.left - margin.right,
    height = 400 - margin.top - margin.bottom;

我们需要调整创建主<svg>容器的代码,以考虑这些边距。

**var** svg = d3.select("#chart1").append("svg")
    .attr("height", height + margin.left + margin.right)
    .attr("width", width + margin.top + margin.bottom);

为了确保我们的图表符合定义的边距,我们将在一个子 SVG 组(<g>)元素内完全构建图表。<g>元素只是 SVG 中的一个任意容器元素,就像 HTML 中的<div>元素一样。我们可以使用 D3.js 来创建这个元素,并将其适当地定位在主<svg>元素中。

**var** chart = svg.append("g")
    .attr("transform",
        "translate(" + margin.left + "," + margin.top + ")"
    );

可视化通常需要对源数据进行重新缩放。在我们的案例中,我们需要将数据重新缩放以适应图表的维度。例如,星系距离不再从 0.5 到 17 Mpc,而是需要缩放到 0 到 920 像素之间。由于这种需求在可视化中很常见,D3.js 提供了相关工具来帮助实现。毫不奇怪,这些工具就是scale对象。我们将为 x 轴和 y 轴的维度创建比例尺。

正如下面的代码所示,我们的两个比例尺都是线性的。线性变换相对简单(而且我们实际上不需要 D3.js 来管理它们);然而,D3.js 还支持其他类型的比例尺,这些比例尺可能相当复杂。在 D3.js 中,使用更复杂的比例尺和使用线性比例尺一样简单。

**var** xScale = d3.scale.linear()
    .range([0,width]);
**var** yScale = d3.scale.linear()
    .range([height,0]);

我们将两个范围定义为每个比例尺的期望极限。x 比例尺的范围是从0到图表的宽度,y 比例尺的范围是从0到图表的高度。不过需要注意的是,我们已经反转了 y 比例尺的常规顺序。这是因为 SVG 的维度(就像 HTML 的维度一样)将 0 放在区域的顶部。这个惯例与常规图表惯例相反,后者将 0 放在底部。为了应对这种反转,我们在定义范围时交换了值。

此时,我们已经为每个刻度设置了范围,这些范围定义了所需的输出。我们还需要为每个刻度指定可能的输入,这些输入在 D3.js 中被称为 domain(领域)。这些输入是距离和速度的最小值和最大值。我们可以使用 D3.js 直接从数据中提取这些值。以下是获取最小距离的方法:

**var** minDist = d3.min(hubble_data, **function**(nebulae) {
    **return** nebulae.distance - nebulae.distance_error;
});

我们不能简单地在数据中找到最小值,因为我们必须考虑到距离误差。正如在前面的代码片段中所看到的,D3.js 接受一个函数作为 d3.min() 的参数,而这个函数可以进行必要的调整。我们也可以对最大值使用相同的方法。以下是定义两个刻度的领域的完整代码:

xScale.domain([
        d3.min(hubble_data, **function**(nebulae) {
            **return** nebulae.distance - nebulae.distance_error;
        }),
        d3.max(hubble_data, **function**(nebulae) {
            **return** nebulae.distance + nebulae.distance_error;
        })
    ])
    .nice();
yScale.domain([
        d3.min(hubble_data, **function**(nebulae) {
            **return** nebulae.velocity - nebulae.velocity_error;
        }),
        d3.max(hubble_data, **function**(nebulae) {
            **return** nebulae.velocity + nebulae.velocity_error;
        })
    ])
    .nice();

第 5 步:绘制图表框架

坐标轴是可视化中的另一个常见特性,D3.js 也为此提供了工具。为了为我们的图表创建坐标轴,我们指定适当的刻度和方向。正如你从以下代码中看到的,D3.js 将坐标轴作为其 SVG 工具的一部分来支持。

**var** xAxis = d3.svg.axis()
    .scale(xScale)
    .orient("bottom");
**var** yAxis = d3.svg.axis()
    .scale(yScale)
    .orient("left");

在定义了坐标轴之后,我们可以使用 D3.js 将适当的 SVG 元素添加到页面中。我们会将每个坐标轴放置在各自的 <g> 组中。对于 x 轴,我们需要将该组移到图表的底部。

**var** xAxisGroup = chart.append("g")
    .attr("transform", "translate(0," + height + ")");

为了创建构成坐标轴的 SVG 元素,我们可以调用 xAxis 对象,并将包含组作为参数传递给它。

xAxis(xAxisGroup);

然而,在 D3.js 中,有一种更简洁的表达方式,它避免了创建不必要的局部变量,并保持方法链的使用。

chart.append("g")
    .attr("transform", "translate(0," + height + ")")
    .call(xAxis);

只要我们保持方法链的使用,就可以利用它再向图表中添加一个元素:这次是轴的标签。

chart.append("g")
    .attr("transform", "translate(0," + height + ")")
    .call(xAxis)
  .append("text")
    .attr("x", width)
    .attr("y", -6)
    .style("text-anchor", "end")
    .text("Distance (Mpc)");

如果你深入查看,你会发现 D3.js 为我们做了很多工作,创建了坐标轴、刻度线和标签。这是它生成的部分 SVG 代码:

**<g** class="x axis" transform="translate(0,450)"**>**
    **<g** class="tick" transform="translate(0,0)" style="opacity: 1;"**>**
        **<line** y2="6" x2="0"**></line>**
        **<text** y="9" x="0" dy=".71em" style="text-anchor: middle;"**>**0**</text>**
    **</g>**
    **<g** class="tick" transform="translate(77.77,0)" style="opacity: 1;"**>**
        **<line** y2="6" x2="0"**></line>**
        **<text** y="9" x="0" dy=".71em" style="text-anchor: middle;"**>**2**</text>**
    **</g>**
    *<!-- Additional tick marks... -->*
    **<path** class="domain" d="M0,6V0H700V6"**></path>**
    **<text** x="700" y="-6" style="text-anchor: end;"**>**Distance (Mpc)**</text>**
**</g>**

当我们为 y 轴添加代码时,我们就完成了图表的框架。

chart.append("g")
    .attr("transform", "translate(0," + height + ")")
    .call(xAxis)
  .append("text")
    .attr("x", width)
    .attr("y", -6)
    .style("text-anchor", "end")
    .text("Distance (Mpc)");

chart.append("g")
    .call(yAxis)
  .append("text")
    .attr("transform", "rotate(-90)")
    .attr("y", 6)
    .attr("dy", ".71em")
    .style("text-anchor", "end")
    .text("Red Shift Velocity (km/s)")

图 7-1 的结果没有数据时看起来并不特别吸引人,但它确实为我们提供了图表的框架。

D3.js 提供了用于创建图表框架的工具。图 7-1. D3.js 提供了用于创建图表框架的工具。

如你所见,为了在页面上添加几个坐标轴,我们已经写了不少代码。这就是 D3.js 的特性。它不是一个你可以简单地传入数据集并获得图表输出的库。相反,应该将其视为一组非常有用的工具,帮助你创建自己的图表。

第 6 步:将数据添加到图表中

现在我们的图表框架已经准备好,我们可以添加实际的数据。由于我们希望显示数据中的距离和速度误差,我们可以将每个点绘制为一个矩形。对于一个简单的静态图表,我们可以像创建其他图表元素一样添加 SVG <rect> 元素。我们可以利用我们的 x 和 y 比例尺来计算矩形的尺寸。

hubble_data.forEach(**function**(nebulae) {
    chart2.append("rect")
      .attr("x", xScale(nebulae.distance - nebulae.distance_error))
      .attr("width", xScale(2 * nebulae.distance_error))
      .attr("y", yScale(nebulae.velocity - nebulae.velocity_error))
      .attr("height", height - yScale(2 * nebulae.velocity_error));
});

上述方法对于这个示例来说效果很好,生成了图表如图 7-2 所示。然而,通常情况下,D3.js 可视化会直接将数据集与标记元素结合,并依赖 D3 的 enterupdateexit 选择来将数据添加到页面上。我们将推迟在下一个示例中讨论这种替代方法。

D3.js 可以使用任何有效的标记语言渲染数据元素,包括具有定义尺寸的 SVG  元素。图 7-2。D3.js 可以使用任何有效的标记语言渲染数据元素,包括具有定义尺寸的 SVG <rect> 元素。

步骤 7:回答用户的问题

每当你创建一个可视化时,提前考虑用户在查看它时可能提出的问题是一个好主意。在我们目前的示例中,我们展示了一个导致哈勃定律的数据集。但我们还没有(尚未)展示这些数据与该定律的拟合程度。由于这是一个显而易见的问题,我们可以直接在图表上回答它。

当前对哈勃常数(H[0])的估计值约为 70 km/s/Mpc。为了展示这个值如何与我们图表中的数据相匹配,我们可以创建一个从(0,0)点开始的直线图。只需要一个简单的 SVG <line> 元素即可。我们再次依赖 D3.js 的比例尺来定义线条的坐标。

chart.append("line")
    .attr("x1",xScale(0))
    .attr("y1",yScale(0))
    .attr("x2",xScale(20))
    .attr("y2",yScale(1400));

在图 7-3 中,我们可以看到哈勃定律仍然是一个很好的近似值。

完整的自定义图表精确地展示了我们所需的数据集。图 7-3。完整的自定义图表精确地展示了我们所需的数据集。

创建一个力导向网络图

与我们在前几章中考虑的 JavaScript 绘图库不同,D3.js 不仅限于标准图表。事实上,它在专业和自定义图形类型方面表现优异。为了展示其强大功能,我们将创建一个来自第四章的网络图的另一个版本。在之前的实现中,我们使用了 Sigma 库,并且大部分工作是将数据结构化为该库所需的格式。我们不需要决定如何绘制节点和边,如何连接它们,或者在启用布局后,如何将它们定位在页面上。正如我们接下来所看到的,D3.js 并不会为我们做这些决策。对于这个例子,我们将需要自己绘制节点和边,适当地连接它们,并将它们定位在页面上。这听起来可能像是很多工作,但正如我们接下来也会看到的,D3.js 提供了许多工具来帮助我们。

第 1 步:准备数据

由于我们正在复制来自第四章的网络图,因此我们从相同的数据集开始。

**var** albums = [
  {
    album: "Miles Davis - Kind of Blue",
    musicians: [
      "Cannonball Adderley",
      "Paul Chambers",
      "Jimmy Cobb",
      "John Coltrane",
      "Miles Davis",
      "Bill Evans"
  ]
},{
  album: "John Coltrane - A Love Supreme",
  musicians: [
    "John Coltrane",
    "Jimmy Garrison",
    "Elvin Jones",
    "McCoy Tyner"
  ]
*// Data set continues...*

对于可视化,拥有两个独立的数组将会很有帮助,一个用于图的节点,一个用于图的边。从原始数据中提取这些数组非常简单,因此我们在本章中不会讨论。你可以在书籍的源代码中看到完整的实现。结果如下所示:

**var** nodes = [
  {
    "name": "Miles Davis - Kind of Blue",
    "links": [
      "Cannonball Adderley",
      "Paul Chambers",
      "Jimmy Cobb",
      "John Coltrane",
      "Miles Davis",
      "Bill Evans"
    ],
    "x": 270,
    "y": 200
  },
  {
    "name": "John Coltrane - A Love Supreme",
    "links": [
      "John Coltrane",
      "Jimmy Garrison",
      "Elvin Jones",
      "McCoy Tyner"
    ],
    "x": 307.303483,
    "y": 195.287474
  },
  *// Data set continues...*
];

对于节点,我们已添加 xy 属性来定义图上的位置。最初,代码任意设置这些值,使得节点呈圆形分布。

**var** edges = [
  {
    "source": 0,
    "target": 16,
    "links": [
      "Cannonball Adderley",
      "Miles Davis"
    ]
  },
  {
    "source": 0,
    "target": 6,
    "links": [
      "Paul Chambers",
      "John Coltrane"
    ]
  },
  *// Data set continues...*
];

边表示它们连接的两个节点,作为 nodes 数组中的索引,并且它们包含在专辑之间共有的独立音乐家的数组。

第 2 步:设置页面

如前一个例子所述,D3.js 不依赖任何其他库,并且它可以在大多数内容分发网络上使用。我们所需要做的就是将其包含在页面中。

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>**
    **<meta** charset="utf-8"**>**
    **<title></title>**
  **</head>**
  **<body>**
    **<div** id="container"**></div>**
    **<script**
      src="//cdnjs.cloudflare.com/ajax/libs/d3/3.4.6/d3.min.js"**>**
    **</script>**
  **</body>**
**</html>**

就像在前一个例子中一样,我们通过包含一个 id 为 "container" 的 <div> 来为可视化设置容器。

第 3 步:为可视化创建舞台

这一阶段与之前的例子相同。

**var** svg = d3.select("#container").append("svg")
    .attr("height", 500)
    .attr("width", 960);

我们要求 D3.js 选择容器元素,然后在其中插入一个 <svg> 元素。我们还通过设置 heightwidth 属性来定义 <svg> 元素的大小。

第 4 步:绘制图表的节点

我们将通过在 <svg> 阶段内附加 <circle> 元素来绘制每个节点。基于前一步,你可能会认为这就像对 nodes 数组中的每个元素执行 svg.append("circle") 一样简单。

nodes.forEach(**function**(node) {
    svg.append("circle");
});

这段代码确实会向可视化中添加 25 个圆形。然而,它不会做的是在数据(数组中的节点)和文档(页面上的圆形元素)之间建立任何链接。D3.js 有另一种方法可以向页面添加圆形,并创建这种链接。事实上,D3.js 不仅会创建链接,它甚至会为我们管理这些链接。随着可视化变得越来越复杂,这种支持变得尤为重要。

注意

这个特性实际上是 D3.js 的核心,事实上,它也是该名称的来源 D3,即** 数据驱动文档 的缩写。

以下是我们如何更有效地使用 D3.js 向图表中添加<circle>元素的方法:

**var** selection = svg.selectAll("circle")
.data(nodes);

selection.enter().append("circle");

如果你以前没见过 D3.js 代码,这段代码肯定看起来很奇怪。我们在没有创建任何 <circle> 元素之前选择它们到底是想做什么呢?结果不就是空的吗?如果是这样,那紧随其后的 data() 函数又有什么意义呢?为了回答这些问题,我们必须理解 D3.js 如何与传统的 JavaScript 库(如 jQuery)不同。在那些库中,选择代表的是 HTML 标记元素。而在 jQuery 中,$("circle") 仅仅是页面上的 <circle> 元素。然而,在 D3.js 中,选择不仅仅是标记元素。D3.js 选择可以同时包含标记数据。

D3.js 使用 data() 函数将标记元素和数据对象结合在一起。它操作的对象(在前面的代码中是 svg.selectAll("circle"))提供了元素,而它的参数(在本例中是 nodes)提供了数据。因此,这段代码的第一条语句告诉 D3.js 我们希望将 <circle> 元素与图中的节点匹配。实际上,我们是在说,希望每个 <circle> 都代表 nodes 数组中的一个值。

当元素的数量与数据值的数量恰好相等时,结果最容易理解。图 7-4 显示了四个 <circle> 元素和四个专辑。D3.js 认真地将这两组数据结合在一起,给我们选择了四个对象。每个对象既包含一个 <circle> 元素,也包含一个专辑。

D3.js 选择可以将页面内容(如  元素)与数据项(如专辑)关联。图 7-4. D3.js 选择可以将页面内容(如 <circle> 元素)与数据项(如专辑)关联。

通常情况下,我们无法保证元素的数量与数据值完全相等。例如,假设只有两个<circle>元素对应我们的四个专辑。如图 7-5 所示,D3.js 仍然会创建四个对象的选择,尽管并没有足够的圆形元素与所有对象匹配。两个对象会有数据值,但没有对应的元素。

D3.js 选择保持页面内容的跟踪,尽管该内容(尚未)存在。图 7-5. D3.js 选择保持页面内容的跟踪,尽管该内容(尚未)存在。

我们的代码片段是一个更极端的例子。当它执行时,页面上根本没有圆形元素。然而,nodes数组中有一些值,我们告诉 D3.js 将其作为数据使用。因此,D3.js 会为每个数据值创建一个对象。只是,它不会为这些数据值创建一个<circle>元素。

(深呼吸,因为魔法即将发生。)

现在我们可以看看代码片段中的第二个语句。它以selection.enter()开头。enter()函数是一个特殊的 D3.js 函数。它告诉 D3.js 在选择中查找所有有数据值但没有标记元素的对象。然后我们通过调用append("circle")来完成语句。通过这个函数调用,D3.js 会为选择中没有标记元素的任何对象创建一个圆形。这就是我们如何将<circle>元素添加到图形中的方式。

为了简洁一点,我们可以将这两条语句合并成一句。

**var** nodeSelection = svg.selectAll("circle")
    .data(nodes)
    .enter().append("circle");

我们的可视化效果是:为图形中的每个节点在 <svg> 容器内创建一个 <circle> 元素。

步骤 5:绘制图形的边

你应该不会感到惊讶,添加边缘到图形的工作方式与添加节点类似。我们只需要追加<line>元素,而不是圆形。

**var** edgeSelection = svg.selectAll("line")
    .data(edges)
    .enter().append("line");

即使在这个例子中我们不需要使用它们,D3.js 还有一些与 enter() 函数互补的其他函数。要查找那些有标记元素但没有数据值的对象,可以使用 exit() 函数。而要查找那些有标记元素并且数据值已改变的对象,可以使用 update() 函数。enterexit 的命名源自 D3.js 所关联的视觉化戏剧隐喻。enter() 子集代表那些进入舞台的元素,而 exit() 子集代表退出舞台的元素。

因为我们使用 SVG 元素来表示节点和边,所以可以使用 CSS 规则来样式化它们。这对边特别重要,因为默认情况下,SVG 线条的描边宽度是0

circle **{**
    **fill:** #ccc**;**
    **stroke:** #fff**;**
    **stroke-width:** 1px**;**
**}**

line **{**
    **stroke:** #777**;**
    **stroke-width:** 1px**;**
**}**

步骤 6:定位元素

到此为止,我们已经为可视化添加了必要的标记元素,但尚未为它们指定任何尺寸或位置。正如之前所述,D3.js 不进行任何绘制工作,因此我们需要编写代码来完成这一任务。正如步骤 2 中所提到的,我们确实通过将节点排列成圆形为它们分配了某些任意位置。现在,我们可以使用这个圆形来定位它们。

要定位 SVG 圆形,我们将其 cxcy 属性设置为圆心的位置。我们还通过 r 属性指定圆的半径。让我们从半径开始;我们将其设置为所有节点的固定值。我们已经为所有这些节点创建了 D3.js 的选择。设置它们的 r 属性是一个简单的语句:

nodeSelection.attr("r", 10);

cxcy 值稍微复杂一些,因为它们对所有节点并不相同。这些值取决于与节点相关的数据属性。更具体地说,nodes 数组中的每个元素都有 xy 属性。不过,D3.js 使得访问这些属性变得非常容易。

nodeSelection
    .attr("r", 10)
    .attr("cx", **function**(dataValue) { **return** dataValue.x; })
    .attr("cy", **function**(dataValue) { **return** dataValue.y; });

我们没有为属性提供常量值,而是提供了函数。D3.js 会调用这些函数并将数据值作为参数传递给它们。我们的函数将返回适当的属性值。

边的定位采用了类似的策略。我们希望将线条的端点设置为相应节点的中心。这些端点是 <line> 元素的 x1,y1x2,y2 属性。以下是设置这些属性的代码:

edgeSelection
    .attr("x1", **function**(d) { **return** nodes[d.source].x; })
    .attr("y1", **function**(d) { **return** nodes[d.source].y; })
    .attr("x2", **function**(d) { **return** nodes[d.target].x; })
    .attr("y2", **function**(d) { **return** nodes[d.target].y; });

按照 D3.js 的惯例,参数 d 是数据值。

随着元素终于被绘制并定位,我们得到了可视化的第一版,见图 7-6。

D3.js 提供工具帮助绘制网络图的圆圈和线条。图 7-6. D3.js 提供工具帮助绘制网络图的圆圈和线条。

步骤 7:为图形添加力导向

图形已经具备了所有基本组件,但其布局并不像我们希望的那样便于识别连接关系。在第四章中,Sigma 库可以通过仅仅几行 JavaScript 代码自动完成布局。为了实现这一自动化,Sigma 使用了一个力导向算法。力导向将节点视为物理对象,并模拟如重力和电磁力等力的作用。

使用 D3.js 时,我们不能依赖库来完全自动化布局。正如我们所看到的,D3.js 并不会绘制任何图形元素,因此它不能单独设置位置和尺寸。然而,D3.js 提供了许多工具,帮助我们创建自己的图形布局。其中一个工具就是力导向布局工具。正如你所料,力导向布局工具帮助我们绘制自己的力导向图。它处理了力方向背后的所有复杂计算,并给我们提供了可以直接在绘制图形的代码中使用的结果。

要开始布局,我们定义一个新的force对象。该对象接受许多配置参数,但只有五个对我们的可视化至关重要:

  • 图形的尺寸

  • 图中的节点

  • 图中的边

  • 我们希望看到的连接节点之间的距离

  • 节点相互排斥的强度,这是 D3.js 所称的电荷参数

最后的参数可能需要一些反复试验,以优化特定的可视化效果。在我们的例子中,我们希望将其大幅提高,超过默认值(-30),因为我们有很多节点且空间很小。(负电荷值表示排斥力。)以下是设置所有这些值的代码:

**var** force = d3.layout.charge()
    .size([width, height])
    .nodes(nodes)
    .links(edges)
    .linkDistance(40)
    .charge(-500);

当我们告诉 D3.js 开始其力的方向计算时,它将在中间步骤和计算完成时生成事件。力的方向通常需要几秒钟才能完全执行,如果我们等到计算完成再绘制图形,用户可能会认为浏览器已经冻结。通常,最好在每次迭代时更新图形,让用户看到进度的某些提示。为此,我们可以添加一个函数来响应中间的力方向计算。这发生在 D3.js 的tick事件上。

force.on("tick", **function**() {
    *// Update graph with intermediate results*
});

每次 D3.js 调用我们的事件处理函数时,它都会更新nodes数组中的xy属性。新值将反映力的方向如何推动节点在图形舞台上的位置。我们可以通过更改圆圈和线条的 SVG 属性来相应地更新我们的图形。然而,在此之前,我们可以利用 D3.js 提供的机会,在执行过程中调整力的方向算法。我们可能遇到的一个问题,尤其是当我们定义了较大的电荷力时,节点可能会相互排斥,导致某些节点完全漂移出舞台。我们可以通过确保节点的位置保持在图形的尺寸范围内来避免这种情况。

force.on("tick", **function**() {
    nodeSelection.each(**function**(node) {
        node.x = Math.max(node.x, 5);
        node.y = Math.max(node.y, 5);
        node.x = Math.min(node.x, width-5);
        node.y = Math.min(node.y, height-5);
    });
    *// Update graph with intermediate results*
});

我们在前面的代码片段中加上或减去了5,以考虑节点圆圈的半径。

一旦我们调整了节点的属性,以确保它们留在舞台上,我们就可以更新它们的位置。代码与我们最初定位它们时使用的代码完全相同。

nodeSelection
    .attr("cx", **function**(d) { **return** d.x; })
    .attr("cy", **function**(d) { **return** d.y; });

我们还需要调整边线的端点。然而,对于这些对象,有一个小的变化。当我们初始化 edges 数组时,我们将 sourcetarget 属性设置为相应节点在 nodes 数组中的索引。当 D3.js 力导向布局工具开始执行时,它会将这些索引替换为对节点本身的直接引用。这使得我们更容易找到适当的坐标来绘制这些边线。

edgeSelection
    .attr("x1", **function**(d) { **return** d.source.x; })
    .attr("y1", **function**(d) { **return** d.source.y; })
    .attr("x2", **function**(d) { **return** d.target.x; })
    .attr("y2", **function**(d) { **return** d.target.y; });

当我们的函数准备好处理来自力导向计算的更新时,我们可以告诉 D3.js 开始工作。这是 force 对象的一个简单方法。

force.start();

通过这行代码,图形开始执行动画过渡到最终的力导向状态,如图 7-7 所示。

D3.js 力导向布局工具提供了重新定位网络图元素的信息。图 7-7. D3.js 力导向布局工具提供了重新定位网络图元素的信息。

第 8 步:添加交互性

由于 D3.js 是一个 JavaScript 库,你可以期望它支持与用户的交互。它确实支持,为了演示这一点,我们可以向图形添加一个简单的交互功能。当用户点击图形中的一个节点时,我们可以突出显示该节点及其邻居。

D3.js 中的事件处理程序与其他 JavaScript 库(如 jQuery)中的事件处理程序非常相似。我们通过选择集的on()方法定义事件处理程序,如以下代码所示。

nodeSelection.on("click", **function**(d) {
    *// Handle the click event*
});

on()的第一个参数是事件类型,第二个参数是 D3.js 在事件发生时调用的函数。该函数的参数是与选择集元素对应的数据对象,通常命名为d。因为我们将事件添加到节点的选择集(nodeSelection),所以d将是图中的一个节点。

对于我们的可视化效果,我们通过为对应的 <circle> 元素添加一个 CSS 可访问类并增大圆的大小来强调被点击的节点。这个类使得我们可以对圆形进行独特的样式设置,但圆形的大小不能通过 CSS 规则来指定。因此,我们最终需要对圆形做两件事:添加 selected 类并使用 r 属性增加半径。当然,为了做到这两点,我们需要选择 <circle> 元素。当 D3.js 调用事件处理程序时,它会将 this 设置为事件的目标;我们可以通过 d3.select(this) 将该目标转换为选择集。因此,以下代码就是改变被点击节点的圆形所需要的全部代码。

d3.select(**this**)
   .classed("selected", **true**)
   .attr("r", 1.5*nodeRadius);

我们也可以通过向所有与点击节点连接的边添加一个 selected 类来做类似的事情。为了找到这些边,我们可以遍历整个边选择集。D3.js 提供了 each() 函数来完成这项工作。

   edgeSelection.each(**function**(edge) {
       **if** ((edge.source === d) || (edge.target === d)) {
➊         d3.select(**this**).classed("selected",**true**);
       }
   });

当我们查看每个边时,我们检查sourcetarget属性,以查看它们是否与我们点击的节点匹配。当我们找到匹配项时,我们将selected类添加到该边。请注意,在➊处我们再次使用d3.select(this)。在这个例子中,代码位于each()函数内部,因此this将等于当前迭代的特定元素。在我们的例子中,就是边的<line>元素。

上述代码处理了设置selected类的问题,但我们仍然需要在适当的时候将其移除。我们可以通过操作节点选择来从所有其他圆圈中移除它(并确保它们的半径恢复到默认值)。

   nodeSelection
➊     .filter(**function**(node) { **return** node !== d; })
       .classed("selected", **false**)
       .attr("r", nodeRadius);

这段代码与我们之前见过的相同,唯一不同的是在➊处我们使用 D3.js 的filter()函数,将选择范围限制为除了被点击的节点之外的其他节点。

类似的过程会重置所有边的selected类。我们可以先从所有边中移除该类,再在前面的代码片段中添加到适当的边上。下面是移除它的代码;在 D3.js 中,只需要一行代码:

edgeSelection.classed("selected", **false**);

最后,如果用户点击的是已经选中的节点,我们可以像这样将其恢复到默认状态:

d3.select(**this**)
    .classed("selected", **true**)
    .attr("r", 1.5*nodeRadius);

当你将所有前面的代码片段组合在一起时,你将得到完整的事件处理程序,如下所示:

nodeSelection.on("click", **function**(d) {

    nodeSelection
        .filter(**function**(node) { **return** node !== d; })
        .classed("selected", **false**)
        .attr("r", nodeRadius);

    edgeSelection.classed("selected", **false**);

    **if** (d3.select(**this**).classed("selected")) {
        d3.select(**this**)
            .classed("selected", **false**)
            .attr("r", nodeRadius)

    } **else** {
        d3.select(**this**)
            .classed("selected", **true**)
            .attr("r", 1.5*nodeRadius);

        edgeSelection.each(**function**(edge) {
             **if** ((edge.source === d) || (edge.target === d)) {
                 d3.select(**this**).classed("selected",**true**);
             }
        });
    }
});

结合一些 CSS 样式,用于突出显示选中的圆圈和线条,这段代码生成了交互式可视化,如图 7-8 所示。

D3.js 包含使可视化具有交互功能的函数。图 7-8。D3.js 包含使可视化具有交互功能的函数。

第 9 步:尝试其他增强功能

我们的示例已经探索了 D3.js 提供的许多自定义可视化功能。然而,到目前为止,代码只触及了 D3 功能的表面。我们还没有为图形添加标签,或者使图形状态的过渡动画化。实际上,如果我们想为可视化添加任何功能,D3.js 几乎总能提供相关工具。尽管我们在这里没有时间或空间讨论其他增强功能,但本书的源代码包含一个功能更全面的实现,利用了其他 D3.js 的功能。

创建一个可缩放的地图

前两个示例介绍了 D3.js 的一些功能,但该库还包含许多其他功能。从第六章的示例中,我们知道一些最好的可视化依赖于地图,而 D3.js 作为一个通用可视化库,对地图有广泛的支持。为了说明这一点,我们将创建一个显示美国本土龙卷风观察的地图。

第 1 步:准备数据

美国国家海洋和大气管理局(* www.noaa.gov/ )在其气候数据在线网站( www.ncdc.noaa.gov/cdo-web/ *)上发布了大量的天气和气候数据。该数据包括美国及其领土内所有报告的风暴事件。我们可以下载 2013 年年度的数据集,格式为逗号分隔值(CSV)文件。由于该文件非常大,并且包含许多不是龙卷风的事件,我们可以使用电子表格应用程序,如 Microsoft Excel 或 Mac 的 Numbers,编辑文件以删除多余的信息。对于这个可视化,我们只需要 event_type"Tornado" 的记录,并且只需要包含龙卷风的纬度、经度和增强富吉塔等级(龙卷风强度的衡量标准)这几列数据。一旦我们适当地修剪了 CSV 文件,它将看起来像下面这样的数据。

f_scale,latitude,longitude
EF1,33.87,-88.23
EF1,33.73,-87.9
EF0,33.93,-87.5
EF1,34.06,-87.37
EF1,34.21,-87.18
EF1,34.23,-87.11
EF1,31.54,-88.16
EF1,31.59,-88.06
EF1,31.62,-87.85
--*snip*--

由于我们将使用 JavaScript 访问这些数据,你可能会想将文件从 CSV 格式转换为 JSON 格式。然而,最好还是将数据保留在 CSV 文件中。D3.js 完全支持 CSV 格式,因此将其转换为 JSON 并不会带来实际的好处。更重要的是,JSON 文件的大小将是 CSV 版本的四倍以上,这额外的大小会导致我们网页加载变慢。

步骤 2:设置页面

我们的骨架网页与其他 D3.js 示例没有什么不同。我们为地图留出了一个容器,并包含了 D3.js 库。

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>**
    **<meta** charset="utf-8"**>**
    **<title></title>**
  **</head>**
  **<body>**
    **<div** id="map"**></div>**
    **<script**
      src="//cdnjs.cloudflare.com/ajax/libs/d3/3.4.6/d3.min.js"**>**
    **</script>**
  **</body>**
**</html>**

步骤 3:创建地图投影

如果你有些记不起关于地图投影的地理课内容,不用担心;D3.js 可以处理所有繁重的工作。它不仅对常见的投影提供广泛的支持,而且还支持为可视化量身定制的自定义投影扩展。例如,有一种经过修改的 Albers 投影,经过优化后适用于美国的分层地图。它重新定位(并调整大小)了阿拉斯加和夏威夷,以提供一个方便的涵盖所有 50 个州的地图。在我们的例子中,由于 2013 年阿拉斯加和夏威夷没有龙卷风出现,因此我们可以使用标准的 Albers 投影。

我们在以下代码中设置了投影。

➊ **var** width = 640,
➋     height = 400;

➌ **var** projection = d3.geo.albers()
➍     .scale(888)
➎     .translate([width / 2, height / 2]);

➏ **var** path = d3.geo.path()
➐     .projection(projection);

首先,在 ➊ 和 ➋,我们定义了地图的像素大小。然后,在 ➌,我们创建了 Albers 投影。D3.js 支持许多调整方式来将投影适当定位到页面上,但在我们的情况下,默认值就足够了。我们只需要在 ➍ 缩放地图,并在 ➎ 居中它。

为了在页面上绘制地图,我们将使用 SVG <path> 元素,但我们的地图数据是以经纬度值的形式呈现的。D3.js 有一个 path 对象,可以根据特定的地图投影将地理坐标转换为 SVG 路径。在 ➏ 和 ➐,我们创建了我们的 path 对象。

步骤 4:初始化 SVG 容器

我们可以创建一个 SVG 容器来容纳地图,就像我们在之前的 D3.js 示例中做的那样。

   **var** svg = d3.select("#map").append("svg")
       .attr("width", width)
       .attr("height", height);

➊  **var** g = svg.append("g");

如我们将在后续步骤中看到的,拥有一个内嵌组将对放置地图非常有帮助。这个内嵌组(由 <g> 元素定义)就像 HTML 中的一个任意 <div> 元素。我们在 ➊ 创建这个内嵌组。

第 5 步:获取地图数据

对于我们的可视化,地图数据就是包含各州的美国地图。D3.js 使用 GeoJSON (geojson.org/) 作为地图数据。与我们在第六章中使用的大多数图像切片不同,GeoJSON 数据是基于矢量的,因此可以在任何比例下使用。GeoJSON 数据也是 JSON 格式,这使得它与 JavaScript 特别兼容。

由于我们的数据是 JSON 格式,我们可以使用 d3.json() 函数来获取数据。这个函数几乎与 jQuery 的 $.getJSON() 函数相同。

d3.json("data/us-states.json", **function**(map) {
    *// Process the JSON map data*
});

第 6 步:绘制地图

一旦我们有了数据,就可以在页面上绘制地图。此步骤中的代码与前一个示例中的非常相似。每个州将是 <g> 容器内的一个 <path> 元素。

➊ g.selectAll("path")
➋     .data(map.features)
➌   .enter().append("path")
➍     .attr("d", path);

使用 D3.js 的约定,我们在 ➊ 创建 <path> 元素的选择,并在 ➋ 将这些元素绑定到我们的数据上。当没有元素时,我们在 ➌ 创建一个,并将其 d 属性设置为与数据相关的路径,根据我们的投影。注意,图 ➍ 中的 path 是我们在第 4 步创建的对象。它是一个函数,用于将纬度和经度信息转换为适当的 SVG 坐标。

如我们在图 7-9 中看到的,D3.js 为我们提供了创建漂亮 SVG 地图所需的路径。

D3.js 帮助从地理 JSON 数据创建矢量地图。图 7-9. D3.js 帮助从地理 JSON 数据创建矢量地图。

第 7 步:获取天气数据

现在我们的地图已准备好接受一些数据。我们可以使用另一个 D3.js 工具来获取 CSV 文件。不过,请注意,CSV 文件的所有属性都被视为文本字符串。我们将需要将这些字符串转换为数字。我们还想过滤掉那些没有包含纬度和经度信息的少数龙卷风目击数据。

   d3.csv("tornadoes.csv", **function**(data) {
➊     data = data.filter(**function**(d, i) {
➋         **if** (d.latitude && d.longitude) {
➌             d.latitude = +d.latitude;
➍             d.longitude = +d.longitude;
➎             d.f_scale = +d.f_scale[2];
➏             d.position = projection([
➐                 d.longitude, d.latitude
               ]);
➑             **return** **true**;
           }
       });
       *// Continue creating the visualization...*
   });

一旦浏览器从服务器检索到 CSV 文件,我们可以在 ➊ 开始处理它。在这里,我们使用数组的 .filter() 方法来遍历数据值。.filter() 方法会去除没有纬度和经度值的数据点。只有在 ➑ 两个值都存在时,它才返回 true ➋。当我们检查数据点的纬度和经度时,我们会在 ➌ 和 ➍ 将字符串值转换为数字,在 ➎ 提取增强富吉塔等级分类中的数字,并使用我们在第 3 步创建的投影函数,在 ➏ 和 ➐ 计算目击位置的 SVG 坐标。

第 8 步:绘制数据

数据获取、清洗和转换完成后,绘制地图上的点变得非常简单。我们再次将使用传统的 D3.js 方法。

   g.selectAll("circle")
       .data(data)
     .enter().append("circle")
       .attr("cx", **function**(d) { **return** d.position[0]; })
       .attr("cy", **function**(d) { **return** d.position[1]; })
➊     .attr("r", **function**(d) { **return** 4 + 2*d.f_scale; });

每个数据点是一个 SVG 的<circle>元素,因此我们选择这些元素,将数据绑定到选择集,并使用.enter()函数创建新的<circle>元素,以匹配数据。

如你所见,我们使用前一步中创建的position属性来设置圆圈的位置。此外,为了表示每个龙卷风的相对强度,我们使圆圈的大小与增强福吉塔等级(➊)的分类成正比。结果如图 7-10 所示,是 2013 年美国本土龙卷风目击情况的一个精美地图。

使用 D3.js 投影将点添加到地图上很容易。图 7-10. 使用 D3.js 投影将点添加到地图上很容易。

第 9 步:添加交互性

地图自然鼓励用户进行缩放和平移,D3.js 使得支持这些标准地图交互变得很容易。事实上,D3.js 赋予了我们完全的控制权,因此我们不受限于标准的地图交互惯例。让我们对地图做些不同的事情。我们可以使用户点击任何一个州来进行缩放。点击一个已经缩放的州则会将地图缩放回默认视图。如你所见,这种行为用 D3.js 实现起来非常简单。

我们将添加的第一段代码是一个变量,用于追踪地图当前缩放的特定州。最初,用户还没有缩放任何位置,所以该变量为空。

**var** active = d3.select(**null**)

接下来,我们为所有州的<path>元素添加一个事件处理器。在我们创建这些元素时(即在第 6 步中),我们已经完成了这一步。

   g.selectAll("path")
       .data(map.features)
     .enter().append("path")
       .attr("d", path)
➊     .on("click", clicked);

额外的语句位于➊。像 jQuery 一样,D3.js 为我们提供了一种简单的方式来为 HTML 和 SVG 元素添加事件处理器。现在,我们需要编写这个事件处理器。

事件处理器需要识别用户点击的州,计算该州的位置(以 SVG 坐标表示),并将地图过渡到这些坐标进行缩放。在详细查看实现之前,值得注意的是,D3.js 事件处理器经过优化,能很好地与数据可视化配合工作(这并不令人惊讶)。具体来说,传递给处理器的参数是与目标元素相关的数据项(通常命名为d)。JavaScript 上下文(this)被设置为接收到事件的特定元素。如果处理器需要访问 JavaScript 事件的其他属性,这些属性可以通过全局变量d3.event获得。以下是这些约定在实际事件处理器中的工作方式:

   **var** clicked = **function**(d) {
➊     active.attr("fill", "#cccccc");
       active = d3.select(**this**)
           .attr("fill", "#F77B15");

➋     **var** bounds = path.bounds(d),
           dx = bounds[1][0] - bounds[0][0],
           dy = bounds[1][1] - bounds[0][1],
           x = (bounds[0][0] + bounds[1][0]) / 2,
           y = (bounds[0][1] + bounds[1][1]) / 2,
➌         scale = .9 / Math.max(dx / width, dy / height),
➍         translate = [
               width / 2 - scale * x,
               height / 2 - scale * y];

➎     g.transition()
           .duration(750)
           .attr("transform", "translate(" +
               translate + ")scale(" +
               scale + ")");
   };

在第一个代码块中(从 ➊ 开始),我们操作了地图的颜色。之前缩放的州被重置为一种灰色,而被点击的州则被填充为鲜艳的橙色。请注意,这段代码还重置了 active 变量,以便准确跟踪缩放的州。接下来,从 ➋ 开始,我们计算了缩放州的边界。或者说,我们让 D3.js 来进行计算。所有的工作都发生在我们在 ➋ 处调用的 bounds() 函数中。其他的代码行主要是提取该计算的各个部分。在 ➌ 处,我们计算了如何缩放地图,以便让缩放的州占据地图的 90% 区域。然后,从 ➍ 开始,我们计算了如何移动地图以使该州居中。最后一块代码,从 ➎ 开始,通过缩放和平移 SVG 来调整地图。正如你所见,我们使用 D3.js 的过渡效果来动画化视图的变化。

到目前为止,我们看到的代码还需要一些小的修改来处理一些未完成的部分,但这些我将留给本书的源代码(* jsDataV.is/source/ *)。图 7-11 中的结果是一个漂亮的互动地图,展示了我们的数据。

D3.js 使得在地图上添加自定义交互变得容易。图 7-11. D3.js 使得在地图上添加自定义交互变得容易。

创建独特的可视化

如果你已经跟随了本章的前三个例子,你可能开始意识到与传统 JavaScript 库相比,D3.js 提供了多少灵活性。它不是为你创建可视化,而是提供了许多工具和功能,供你按需使用。我们利用这种灵活性向图表中添加了非常规的误差条,调整了网络图的行为,并定制了用户与地图的互动。然而,使用 D3.js 时,我们并不局限于对现有可视化类型进行微调。相反,我们可以利用这个库创造出与传统库完全不同的独特可视化效果。

在这个例子中,我们将使用前面可视化中的相同数据——来自美国国家海洋和大气管理局气候数据在线网站的 2013 年龙卷风目击记录(* www.noaa.gov/cdo-web/ )。然而,我们不会将这些目击记录放在地图上,而是会创建一个互动式的层次化可视化,让用户通过区域、州,甚至是州内的县来理解目击次数。对于这个主题,圆形层次结构尤其有效,因此我们将创建一个带有旋转动画的旭日图可视化。接下来的代码基于 Mike Bostock(D3.js 的首席开发者)开发的一个示例( bl.ocks.org/mbostock/4348373/ *)。

注意

也可以使用一些图表库创建日晕(sunburst)可视化,通常通过定制饼图的变体来实现。然而,这些图表库更专注于现成的使用。而使用像 D3.js 这样的库来创建自定义可视化则通常更容易,它特别设计用于定制化。

第 1 步:准备数据

如前所述,我们将清理并修剪 2013 年的龙卷风目击数据集。不过,这一次我们将保留州和县,而不使用经度、纬度和增强富士塔等级分类。我们还将添加一个区域名称,用于将各州分组。结果生成的 CSV 文件如下所示。

state,region,county
Connecticut,New England,Fairfield County
Connecticut,New England,Hartford County
Connecticut,New England,Hartford County
Connecticut,New England,Tolland County
Maine,New England,Somerset County
Maine,New England,Washington County
Maine,New England,Piscataquis County
--*snip*--

第 2 步:设置页面

我们的基础网页与其他 D3.js 示例没有什么不同。我们为可视化预留了一个容器,并包含了 D3.js 库。

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>**
    **<meta** charset="utf-8"**>**
    **<title></title>**
  **</head>**
  **<body>**
    **<div** id="chart"**></div>**
    **<script**
      src="//cdnjs.cloudflare.com/ajax/libs/d3/3.4.6/d3.min.js"**>**
    **</script>**
  **</body>**
**</html>**

第 3 步:为可视化创建舞台

与我们其他的 D3.js 示例一样,我们首先创建一个 <svg> 容器来存放可视化图形。在这个容器内,我们还将添加一个 <g> 元素。

   **var** width = 640,
       height = 400,
➊     maxRadius = Math.min(width, height) / 2;

   **var** svg = d3.select("#chart").append("svg")
       .attr("width", width)
       .attr("height", height);

   **var** g = svg.append("g");
➋     .attr("transform", "translate(" +
           (width / 2) + "," +
           (height / 2) + ")");

这段代码包含了一些新的细节。首先,在 ➊ 处,我们计算了可视化的最大半径。这个值——即高度或宽度的一半,取较小者——将在后续的代码中派上用场。更有趣的是,从 ➋ 开始,我们对内部的 <g> 容器进行平移,使其坐标系的原点(0,0)恰好位于可视化图形的中心。这个平移使得日晕的居中变得容易,同时也能计算出日晕的参数。

第 4 步:创建刻度

当完成时,我们的可视化将由对应于美国各个地区的面积构成;更大的面积将代表龙卷风更多的地区。因为我们处理的是面积数据,所以每个地区需要两个维度。但我们不会将这些面积绘制成简单的矩形;而是要使用弧形。这需要一些三角学知识,但幸运的是,D3.js 提供了大量的帮助。我们将首先定义一些 scale 对象。我们在《适配传统图表类型》的第 4 步中首次看到过刻度,我们用它们来将数据值转换为 SVG 坐标。以下代码中的刻度实现了类似功能,不同之处在于它们使用的是极坐标。

**var** theta = d3.scale.linear()
    .range([0, 2 * Math.PI]);
**var** radius= d3.scale.sqrt()
    .range([0, maxRadius]);

如你所见,角度刻度是一个从 0 到 2π(或 360°)的线性刻度。径向刻度的范围从 0 到最大半径,但它不是线性的。相反,这个刻度是一个平方根刻度;D3.js 在计算输出之前,会对输入值进行平方根处理。弧形的面积随着半径的平方变化,平方根刻度能够补偿这一效果。

注意

在之前的示例中,我们为刻度设置了范围(输出)和域(输入)。然而,在本例中,我们不需要显式地设置域。[0,1] 的默认域正是我们对这两个刻度所需的。

我们定义的刻度在接下来的代码中非常有用,我们将在其中定义一个函数来计算单个弧形的 SVG 路径。大多数工作发生在 D3.js 函数 d3.svg.arc() 中,该函数计算弧形路径。然而,该函数需要四个参数:起始角度、结束角度、起始半径和结束半径。这些参数的值来自我们的刻度。

当我们在代码中稍后使用 arc() 函数时,我们会使用 D3.js 的选择集来调用它。该选择集将与一个数据值相关联,并且数据值将包含四个属性:

  • .x 数据的起始 x 位置

  • .dx 数据在 x 轴上的长度(Δx

  • .y 数据的起始 y 位置

  • .dx 数据在 y 轴上的长度(Δy

根据这些属性,以下是生成弧形路径的代码。

**var** arc = d3.svg.arc()
    .startAngle(**function**(d) {
        **return** Math.max(0, Math.min(2 * Math.PI, theta(d.x)));
    })
    .endAngle(**function**(d) {
        **return** Math.max(0, Math.min(2 * Math.PI, theta(d.x + d.dx)));
    })
    .innerRadius(**function**(d) {
        **return** Math.max(0, radius(d.y));
    })
    .outerRadius(**function**(d) {
        **return** Math.max(0, radius(d.y + d.dy));
    });

代码本身相当直观,但一张图能更好地解释为什么我们要这样使用代码。假设与选择集相关联的数据具有 (x,y) 位置为 (12.5,10),宽度为 25,高度为 30。数据属性将如下所示:

  • .x = 12.5

  • .dx = 25

  • .y = 10

  • .dy = 30

使用笛卡尔坐标系,我们可以像图 7-12 左侧那样绘制选择集。我们的刻度和弧形函数将把矩形转换为图中右侧所示的弧形。

D3.js 帮助将矩形区域转换为弧形。图 7-12. D3.js 帮助将矩形区域转换为弧形。

我们尚未指定 x 轴和 y 轴的刻度范围,但暂时假设每个范围是从 0 到 100。因此,起始的 x 值 12.5,表示该范围的 12.5%。当我们将这个值转换为极坐标时,结果将是完整 360° 范围的 12.5%。也就是 45°,或 π/4。x 值再延伸 25%,所以最终的 x 值会再增加 90°,或 π/2,作为起始值。对于 y 值,我们的刻度会对其进行平方根转换,并将结果映射到 0 到 250(maxRadius)的范围。因此,初始值 10 会被除以 100(表示范围),并转换为 ,也就是 79。最终值 10 + 30 将产生一个半径值为 ,也就是 158。这就是为每个数据值创建 SVG 的过程。

第 5 步:获取数据

在初步准备工作完成后,我们现在可以开始处理数据。像之前的示例一样,我们将使用 d3.csv() 从服务器获取 CSV 文件。

d3.csv("tornadoes.csv", **function**(data) {
    *// Continue processing the data...*
});

当 D3.js 获取文件时,它会创建一个类似以下片段的数据结构。

 {
    "state":"Connecticut",
    "region":"New England",
    "county":"Fairfield County"
  },{
    "state":"Connecticut",
    "region":"New England",
    "county":"Hartford County"
  },{
    "state":"Connecticut",
    "region":"New England",
    "county":"Hartford County"
  },
*// Data set continues...*

该数据结构反映了数据本身,但它没有包含我们绘制弧形所需的 .x.dx.y.dy 属性。计算这些值还需要进一步的工作。如果你回想一下本章的第二个示例,我们以前见过这种情况。我们有一组原始数据,但我们需要用额外的属性来增强这些数据以进行可视化。在之前的示例中,我们使用 D3.js 的力导向布局来计算这些额外的属性。在这种情况下,我们可以使用分区布局来实现。

然而,在使用分区布局之前,我们必须重新结构化数据。分区布局需要层次化的数据,而现在我们只有一个单维数组。我们必须将数据结构化,以反映区域、州和县的自然层级结构。不过,在这里 D3.js 仍然能够帮助我们。d3.nest() 操作符分析一个数据数组并从中提取层次结构。如果你熟悉数据库命令,它相当于 D3.js 中的 GROUP BY 操作。我们可以使用这个操作符来创建数据的新版本。

➊ **var** hierarchy = {
       key: "United States",
       values: d3.nest()
➋         .key(**function**(d) { **return** d.region; })
           .key(**function**(d) { **return** d.state; })
           .key(**function**(d) { **return** d.county; })
➌         .rollup(**function**(leaves) {
➍             **return** leaves.length;
           })
➎         .entries(data)
       };

首先,在 ➊ 处,我们定义了一个变量来保存我们重新结构化后的数据。它是一个具有两个属性的对象。.key 属性被设置为 "United States",而 .values 属性则是 d3.nest() 操作的结果。从 ➋ 开始,我们告诉操作符按 .region、然后按 .state,最后按 .county 来对数据进行分组。然后,在 ➌ 和 ➍ 处,我们告诉操作符将最终值设置为每个分组中的条目计数。最后,在 ➎ 处,我们将原始数据集传递给操作符。当这个语句完成时,hierarchy 变量包含了一个结构化的数据版本,其开头类似于以下片段:

{
    "key": "United States",
    "values": [
        {
            "key": "New England",
            "values": [
                {
                    "key": "Connecticut",
                    "values": [
                        {
                            "key": "Fairfield County",
                            "values": 1
                        },{
                            "key": "Hartford County",
                            "values": 2
                        },{
*// Data set continues...*

这种结构与分区布局的需求相匹配,但我们还需要进行一步操作。d3.nest() 操作符将子数组和叶子数据放在 .values 属性中。然而,默认情况下,分区布局期望数据使用不同的属性名称来表示每种类型的属性。更具体来说,它期望子节点存储在 .children 属性中,而数据值存储在 .value 属性中。由于 d3.nest() 操作符并没有创建完全符合这种结构的结果,我们需要扩展默认的分区布局。下面是实现这一操作的代码:

   **var** partition = d3.layout.partition()
➊     .children(**function**(d) {
➋         **return** Array.isArray(d.values) ? d.values : **null**;
       })
➌     .value(**function**(d) {
➍         **return** d.values;
       });

在 ➊ 和 ➋ 处,我们提供了一个自定义函数来返回节点的子节点。如果节点的 .values 属性是一个数组,那么该属性就包含了子节点。否则,节点没有子节点,我们返回 null。然后,在 ➌ 和 ➍ 处,我们提供了一个自定义函数来返回节点的值。由于这个函数只在没有子节点时使用,因此 .values 属性必须包含节点的值。

第 6 步:绘制可视化效果

到目前为止已经花了一些功夫,但现在我们已经准备好绘制可视化效果了。这是我们为所有工作付出的代价的回报。只需几行代码就可以创建可视化效果。

➊  **var** path = g.selectAll("path")
       .data(partition.nodes(hierarchy))
➋     .enter().append("path")
➌       .attr("d", arc);

这段代码遵循了我们在所有 D3.js 示例中使用的相同结构。在➊处,我们创建了表示数据的 SVG 元素的选择集;在本例中,我们使用的是<path>元素。然后,我们使用自定义的分区布局将选择集与层次数据绑定。在➋处,我们识别出那些尚未(或还没有)关联 SVG 元素的数据值,而在➌处,我们为这些值创建新的元素。最后一步依赖于我们在步骤 4 中创建的.arc()函数。虽然我们还没有添加颜色或标签,但从[图 7-13 可以看出,我们已经走在了正确的道路上。

D3.js 处理创建 Sunburst 图所需的数学运算。图 7-13. D3.js 处理创建 Sunburst 图所需的数学运算。

第 7 步:给区域上色

现在我们可以将注意力转向为可视化上色。我们希望给每个区域一个独特的主色,并为该区域内的各州和县使用该颜色的不同色调。一个好的起点是使用 D3.js 的另一种比例尺——分类颜色比例尺。到目前为止,我们所看到的所有比例尺都是基数比例尺;它们将数值映射到可视化的属性上。分类比例尺处理的则是非数值型数据;这些值仅仅代表某一量的不同类别。在我们的例子中,区域代表的是分类数据。毕竟,"新英格兰"或"西南部"本身并没有什么数值含义。

正如名称所示,分类的颜色比例尺将不同的类别值映射到不同的颜色。D3.js 包含了几种预定义的颜色比例尺。由于我们的数据中区域少于 10 个,d3.scale.category10()比例尺非常适合这个例子。图 7-14 显示了该比例尺中的颜色。

D3.js 为分类数据提供颜色比例尺。图 7-14. D3.js 为分类数据提供颜色比例尺。

我们接下来的任务是将该比例尺的颜色分配到可视化中的弧形区域。为此,我们将定义我们自己的color()函数。该函数将接受分区布局中的数据节点作为输入。

➊ **var** color = **function**(d) {
       **var** colors;
       **if** (!d.parent) {
➋         colors = d3.scale.category10();
➌         d.color = "#fff";
       }

       *// More code needed...*

首先,在➊处,我们创建了一个局部变量,用于存储颜色。接着,我们检查输入节点是否为层次结构的根节点。如果是,我们就在➋处为该节点的子节点创建颜色比例尺,并在➌处为该节点分配自己的颜色。在我们的可视化中,代表整个美国的根节点将是白色的。该分配的颜色最终会由函数返回。

在为子节点创建颜色比例之后,我们需要将个别颜色分配给这些节点。然而,有一个小问题。d.children数组中的节点不一定按我们希望的顺时针顺序分布。为了确保我们颜色比例中的颜色按顺序分布,我们必须先对d.children数组进行排序。以下是这一步的完整代码。

   **if** (d.children) {
➊     d.children.map(**function**(child, i) {
           **return** {value: child.value, idx: i};
➋     }).sort(**function**(a,b) {
             **return** b.value - a.value
➌     }).forEach(**function**(child, i) {
           d.children[child.idx].color = colors(i);
       });
   }

在第一行,我们确保有一个子节点数组。如果有,我们会创建该子节点数组的副本,副本只包含节点值及其原始数组索引,见➊。然后,在➋,我们根据节点值对副本进行排序。最后,在➌,我们遍历排序后的数组,并为子节点分配颜色。

到目前为止,我们已经创建了一个分类颜色比例并将其颜色分配给了第一层子节点。这解决了区域的颜色问题,但还有州和县需要颜色。对于这些,我们可以基于父节点颜色创建一个不同的比例。让我们回到函数定义,并为非根节点添加一个else分支。在这个分支中,我们也为子节点创建一个颜色比例。然而,这些子节点不是区域;它们是州或县。对于一个区域的州和一个州的县,我们不希望使用像分类比例那样的独特颜色。相反,我们希望颜色与父节点的颜色相关。这就需要一个线性渐变。

   **var** color = **function**(d) {
       **var** colors;
       **if** (!d.parent) {
           *// Handle root node as above...*
       } **else** **if** (d.children) {

➊         **var** startColor = d3.hcl(d.color)
                               .darker(),
               endColor = d3.hcl(d.color)
                               .brighter();

➋         colors = d3.scale.linear()
➌                 .interpolate(d3.interpolateHcl)
➍                 .range([
                       startColor.toString(),
                       endColor.toString()
                   ])
➎                 .domain([0,d.children.length+1]);
       }

       *// Code continues...*

从➊开始,我们定义渐变的起始色和结束色。为了创建这些颜色,我们从父节点的颜色(d.color)开始,并将其加深或加亮。在这两种情况下,我们使用色调、饱和度和亮度(HCL)作为颜色操作的基础。HCL 颜色空间基于人类的视觉感知,不同于更常见的 RGB 颜色空间那样的纯数学基础。使用 HCL 通常能得到更具视觉吸引力的渐变效果。

从➋开始的代码块实际上创建了渐变。我们使用的是 D3.js 的线性比例和内置的 HCL 颜色插值算法 ➌。我们的渐变在起始和结束颜色之间变化 ➍,它的域是节点子节点的索引 ➎。

现在我们需要做的就是在创建每个数据值的<path>元素时分配适当的颜色。这只需要在创建路径的代码中添加一行.attr("fill", color)

**var** path = g.selectAll("path")
    .data(partition.nodes(hierarchy))
  .enter().append("path")
    .attr("d", arc)
    .attr("fill", color);

如图 7-15 所示,我们的可视化现在包含了合适的颜色。

D3.js 提供了工具,将吸引人的颜色添加到可视化中,比如我们的日冕图。图 7-15. D3.js 提供了工具,将吸引人的颜色添加到可视化中,比如我们的日冕图。

第 8 步:使可视化交互式

为了总结这个例子,我们将增加一些交互功能。当用户点击图表中的某个区域时,图表将缩放以显示该区域的更多细节。为了强调主题,我们将为这个缩放效果创建一个自定义的旋转动画效果。这个步骤中最简单的部分是添加处理点击事件的函数。我们可以在将 <path> 元素添加到页面时完成这一操作。

   **var** path = g.selectAll("path")
       .data(partition.nodes(hierarchy))
       .enter().append("path")
         .attr("d", arc)
         .attr("fill", color)
➊         .on("click", handleClick);

位于 ➊ 处的 handleClick 函数是我们需要编写的事件处理程序。从概念上讲,这个函数相当简单。当用户点击某个区域时,我们希望修改所有的路径,使该区域成为可视化的焦点。完整的函数在以下代码中展示。

**function** handleClick(datum) {
    path.transition().duration(750)
        .attrTween("d", arcTween(datum));
};

函数的唯一参数是与点击元素对应的数据值。通常情况下,D3.js 使用 d 作为该值;然而,在本例中,为了避免与 SVG 的 "d" 属性混淆,我们使用 datum。函数中的第一行引用了可视化中的所有路径,并为这些路径设置了动画过渡效果。接下来的这一行告诉 D3.js 我们要进行过渡的值。在这个例子中,我们正在更改 <path> 元素的一个属性(因此我们使用 attrTween 函数),而我们更改的具体属性是 "d" 属性(该函数的第一个参数)。第二个参数 arcTween(datum) 是一个返回函数的函数。

下面是 arcTween() 的完整实现。

**function** arcTween(datum) {
    **var** thetaDomain = d3.interpolate(theta.domain(),
                         [datum.x, datum.x + datum.dx]),
        radiusDomain = d3.interpolate(radius.domain(),
                         [datum.y, 1]),
        radiusRange = d3.interpolate(radius.range(),
                         [datum.y ? 20 : 0, maxRadius]);

    **return** **function** calculateNewPath(d, i) {
        **return** i ?
            **function** interpolatePathForRoot(t) {
                **return** arc(d);
            } :
            **function** interpolatePathForNonRoot(t) {
                theta.domain(thetaDomain(t));
                radius.domain(radiusDomain(t)).range(radiusRange(t));
                **return** arc(d);
            };
    };
};

你可以看到,这段代码定义了几个不同的函数。首先是 arcTween(),它返回另一个函数 calculateNewPath(),而 这个 函数返回的是 interpolatePathForRoot()interpolatePathForNonRoot()。在查看实现的细节之前,先让我简单介绍一下这些函数之间的区别。

  • arcTween() 会在点击事件处理程序中被调用一次(针对单次点击)。它的输入参数是与点击元素对应的数据值。

  • 接着,calculateNewPath() 会针对每个路径元素调用一次,每次点击总共调用 702 次。它的输入参数是路径元素的数据值和索引。

  • interpolatePathForRoot()interpolatePathForNonRoot() 会针对每个路径元素多次调用。每次调用都会提供输入参数 t(表示时间),它代表当前动画过渡的进度。时间参数的范围从动画开始时的 0 到动画结束时的 1。例如,如果 D3.js 需要 100 个独立的动画步骤来完成过渡,那么这些函数将在每次点击时被调用 70,200 次。

现在我们知道了这些函数何时被调用,我们可以开始查看它们实际做了什么。一个具体的例子无疑会有所帮助,所以让我们来看看用户点击肯塔基州时会发生什么。如图 7-16 所示,它位于可视化图表的右上部分第二行。

突出显示肯塔基州的龙卷风观测图图 7-16. 突出显示肯塔基州的龙卷风观测图

与此 SVG <path> 相关的数据值将由分区布局计算得出,具体包括:

  • 一个x值为 0.051330798479087454

  • 一个y值为 0.5

  • 一个dx值为 0.04182509505703422

  • 一个dy值为 0.25

在我们的可视化中,该区域从 18.479°的角度位置(x)开始,接着延续 15.057°(dx)。它的最内层半径从离中心 177 像素(y)的位置开始。当用户点击肯塔基州时,我们希望可视化聚焦于肯塔基州及其县。这正是图 7-17 突出显示的区域。角度从 18.479°开始,继续延伸 15.057°;半径从 177 像素开始,直到maxRadius值,总长度为 73 像素。

当用户点击肯塔基州时,我们希望可视化聚焦于那个小区域。图 7-17. 当用户点击肯塔基州时,我们希望可视化聚焦于那个小区域。

具体的例子有助于解释arcTween()的实现。该函数首先创建三个d3.interpolate对象。这些对象提供了一种方便的方式来处理插值的数学计算。第一个对象将起始theta域(最初为 0 到 1)插值到我们所需的子集(肯塔基州的范围是 0.051 到 0.093)。第二个对象对半径做相同的操作,将起始半径域(最初为 0 到 1)插值到我们所需的子集(肯塔基州及其县的范围是 0.5 到 1)。最后一个对象为半径提供了一个新的插值范围。如果点击的元素具有非零的y值,则新的范围将从 20 开始,而不是从 0 开始。如果点击的元素是表示整个美国的<path>,那么范围将恢复到初始的起始值 0。

arcTween()在创建d3.interpolate对象后返回calculateNewPath函数。D3.js 为每个<path>元素调用此函数一次。执行时,calculateNewPath()检查相关的<path>元素是否为根元素(代表整个美国)。如果是,calculateNewPath()返回interpolatePathForRoot函数。对于根元素,不需要插值,所需路径就是我们的arc()函数(来自第 4 步)创建的常规路径。然而,对于所有其他元素,我们使用d3.interpolate对象重新定义thetaradius比例尺。我们将这些比例尺设置为所需的焦点区域,而不是完整的 0 到 2π和 0 到maxRadius。此外,我们使用过渡中的进度量t来插值我们距离这些期望值有多近。重新定义比例尺后,调用arc()函数返回适合新比例尺的路径。随着过渡的进行,路径会重新塑造以适应期望的结果。您可以在图 7-18 中看到中间步骤。

平滑过渡使可视化效果缩放到焦点区域。图 7-18. 平滑过渡使可视化效果缩放到焦点区域。

通过这最后一部分代码,我们的可视化完成了。图 7-19 展示了结果。它包括一些额外的悬停效果,而不是真正的图例;您可以在本书的源代码中找到完整的实现(jsDataV.is/source/)。

D3.js 提供了构建像这样的复杂自定义交互式可视化所需的所有工具。图 7-19. D3.js 提供了构建像这样的复杂自定义交互式可视化所需的所有工具。

总结

正如我们在这些示例中看到的,D3.js 是一个非常强大的用于构建 JavaScript 可视化的库。要有效地使用它,需要比本书中看到的大多数其他库更深入地了解 JavaScript 技术。然而,如果您投入学习 D3.js,您将对结果拥有更多的控制和灵活性。

第八章. 在浏览器中管理数据

到目前为止,在本书中,我们已经探讨了许多可视化工具和技术,但我们并未花太多时间考虑数据可视化中的数据部分。在许多情况下,侧重可视化是合适的。尤其是当数据是静态时,我们可以在其呈现在 JavaScript 中之前,花费大量时间进行清理和组织。但如果数据是动态的,我们别无选择,只能将原始数据源直接导入到我们的 JavaScript 应用程序中?对于来自第三方 REST API、Google Docs 表格或自动生成的 CSV 文件的数据,我们的控制能力要小得多。在这些类型的数据源中,我们常常需要在浏览器中验证、重新格式化、重新计算或以其他方式操作数据。

本章探讨了一个特别有助于在 Web 浏览器中管理大型数据集的 JavaScript 库:Underscore.js (underscorejs.org/). 我们将覆盖 Underscore.js 的以下方面:

  • 函数式编程,Underscore.js 推崇的编程风格

  • 使用 Underscore.js 工具操作简单数组

  • 增强 JavaScript 对象

  • 操作对象集合

本章的格式与本书的其他章节有所不同。我们不会覆盖几个中等复杂度的示例,而是会看许多简单而简短的示例。每个部分将几个相关的示例放在一起,但每个短小的示例都是独立的。第一部分则更加不同,它是一个简短的函数式编程介绍,呈现为从更常见的命令式编程风格逐步迁移的过程。理解函数式编程非常有帮助,因为它的哲学基础贯穿于几乎所有 Underscore.js 的工具。

本章将带领我们深入了解 Underscore.js 库,特别关注数据管理。(作为本书整体专注于数据可视化的妥协,它还包括了一些插图。)在随后的章节中,我们将看到许多本章介绍的 Underscore.js 工具在一个更大的 Web 应用项目中的实际应用。

使用函数式编程

当我们处理作为可视化一部分的数据时,我们通常需要逐项遍历数据,进行转换、提取或其他方式的操作,以使其适应我们的应用程序。仅使用核心的 JavaScript 语言时,我们的代码可能会依赖以下的for循环:

**for** (**var** i=0, len=data.length; i<len; i++) {
    *// Code continues...*
}

尽管这种风格,称为 命令式编程,是 JavaScript 中常见的习惯用法,但在大型复杂应用程序中,它可能会带来一些问题。特别是,它可能导致调试、测试和维护起来比必要的更加困难。本节介绍了一种不同的编程风格——函数式编程,它消除了许多这些问题。正如你将看到的,函数式编程可以使代码更加简洁和可读,因此通常更不容易出错。

为了比较这两种编程风格,我们来考虑一个简单的编程问题:编写一个函数来计算斐波那契数列。前两个斐波那契数是 0 和 1,后续的数是前两个数的和。这个数列如下所示:

  • 0,1,1,2,3,5,8,13,21,34,55,89,...

第 1 步:从命令式版本开始

首先,我们来考虑一个传统的命令式方法来解决这个问题。以下是第一次尝试:

**var** fib = **function**(n) {
    *// If 0th or 1st, just return n itself*
    **if** (n < 2) **return** n;

    *// Otherwise, initialize variable to compute result*
    **var** f0=0, f1=1, f=1;

    *// Iterate until we reach n*
    **for** (i=2; i<=n; i++) {

        *// At each iteration, slide the intermediate*
        *// values down a step*
        f0 = f1 = f;

        *// And calculate sum for the next pass*
        f = f0 + f1;
    }

    *// After all the iterations, return the result*
    **return** f;
}

这个 fib() 函数的输入是一个参数 n,输出是第 n 个斐波那契数。(按照惯例,第 0 个和第 1 个斐波那契数是 0 和 1。)

第 2 步:调试命令式代码

如果你没有仔细检查,可能会惊讶地发现,上面的简单例子中包含了三个错误。当然,这只是一个人为构造的例子,错误是故意设置的,但你能在不继续阅读的情况下找到所有错误吗?更重要的是,如果连一个简单的例子都能隐藏这么多错误,那么你能想象复杂的 Web 应用程序中可能潜伏着什么问题吗?

为了理解为什么命令式编程会引入这些错误,我们来逐个修复它们。

一个错误出现在 for 循环中:

**for** (i=2; i<=n; i++) {

决定循环终止条件的条件检查了一个小于或等于 (<=) 的值;但它应该检查一个小于 (<) 的值。

第二个错误出现在这一行:

f0 = f1 = f;

虽然我们从左到右思考和阅读(至少在英语中是这样),但 JavaScript 的赋值是从右到左执行的。这个语句并没有在我们的变量中移位,而是将 f 的值赋给了所有三个变量。我们需要将这个单一的语句分成两个:

f0 = f1;
f1 = f;

最终的错误是最微妙的,且也出现在 for 循环中。我们使用了局部变量 i,但并没有声明它。因此,JavaScript 会将其视为全局变量。这样虽然不会导致函数返回错误结果,但可能会在我们应用程序的其他部分引入冲突——并且这是一个难以发现的 bug。正确的代码应该将该变量声明为局部变量:

**for** (**var** i=2; i<n; i++) {

第 3 步:理解命令式编程可能引入的问题

这段简短而直接的代码中的错误旨在展示命令式编程中一些常见的有问题的特性。特别是,条件逻辑和状态变量,由于其固有特性,往往会引入某些错误。

考虑第一个错误。它的错误在于使用了不正确的条件测试(<= 而不是 <)来终止循环。精确的条件逻辑对于计算机程序至关重要,但这种精确度并非每个人,甚至包括程序员,都会自然掌握。条件逻辑必须是完美的,而有时做到这一点并不容易。

另外两个错误都与状态变量有关,第一种情况是f0f1,第二种情况是i。这里再次体现了程序员的思维方式与程序实际操作之间的差异。当程序员编写代码以迭代数字时,他们可能集中精力处理当前特定的问题,可能会忽视对应用程序其他部分的潜在影响。从技术上讲,状态变量会在程序中引入副作用,副作用可能导致程序中的错误。

步骤 4: 使用函数式编程风格重写

函数式编程的支持者认为,通过消除条件语句和状态变量,函数式编程风格可以生成比命令式编程更加简洁、易于维护且更不容易出错的代码。

“函数式”在“函数式编程”中的含义并不是指编程语言中的函数,而是指像y=f(x)这样的数学函数。函数式编程试图在计算机编程的背景下模仿数学函数。它通常使用递归来代替通过for循环迭代值,在递归中,一个函数会多次调用自身来进行计算或操作值。

下面是我们如何用函数式编程实现斐波那契算法:

**var** fib = **function**(n) { **return** n < 2 ? n : fib(n-1) + fib(n-2); }

请注意,这个版本没有状态变量,除了处理 0 或 1 的边界情况外,也没有条件语句。它简洁得多,而且请注意代码几乎逐字镜像了原问题的陈述:“前两个斐波那契数是 0 和 1”对应于n < 2 ? n,而“后续的数字是前两个值的和”对应于fib(n-1) + fib(n-2)

函数式编程的实现通常直接表达所需的结果。因此,它们可以最大限度地减少中间算法中误解或错误的可能性。

步骤 5: 评估性能

从我们目前所见,似乎我们应该始终采用函数式编程风格。毫无疑问,函数式编程有其优势,但它也可能有一些显著的缺点。斐波那契代码就是一个完美的例子。由于函数式编程避免使用循环的概念,我们的示例改用递归。

在我们的特定案例中,fib()函数在每一层都会调用自己两次,直到递归达到 0 或 1。由于每次中间调用都会导致更多的中间调用,fib()的调用次数呈指数增长。通过执行fib(28)来找出第 28 个斐波那契数时,将导致超过一百万次对fib()函数的调用。

正如你想象的那样,最终的性能是完全无法接受的。表 8-1的执行时间")展示了fib()`函数的函数式版本和命令式版本的执行时间。

表 8-1. fib()的执行时间

版本 参数 执行时间(毫秒)
命令式 28 0.231
函数式 28 296.9

正如你所看到的,函数式编程版本的执行速度比命令式版本慢了超过一千倍。在实际应用中,这样的性能通常是不可接受的。

步骤 6:解决性能问题

幸运的是,我们可以在不牺牲性能的情况下,享受函数式编程的好处。我们只需要借助小巧却强大的 Underscore.js 库。正如该库的网页所解释的,

Underscore 是一个为 JavaScript 提供功能编程支持的工具库。

当然,我们需要在网页中包含这个库。如果你是单独引入库,Underscore.js 可以通过许多内容分发网络提供,例如 CloudFlare。

<!DOCTYPE html>
**<html** lang="en"**>**
  **<head>**
    **<meta** charset="utf-8"**>**
    **<title></title>**
  **</head>**
  **<body>**
    *<!-- Content goes here -->*
    **<script**
      src="//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.4.4/"+
          "underscore-min.js"**>**
    **</script>**
  **</body>**
**</html>**

使用 Underscore.js 后,我们现在可以优化我们的 Fibonacci 实现的性能。

递归实现的问题在于它会导致很多不必要的 fib() 调用。例如,执行 fib(28) 会需要超过 100,000 次对 fib(3) 的调用。而每次调用 fib(3) 时,返回值都会从头开始重新计算。如果实现只在第一次调用时调用 fib(3),然后每次需要知道 fib(3) 的值时都能重用之前的结果,而不是重新计算,这将会更好。实际上,我们希望在 fib() 函数前实现一个缓存。这个缓存可以消除重复计算。

这种方法被称为 记忆化,而 Underscore.js 库提供了一个简单的方法来自动透明地记忆化 JavaScript 函数。毫不奇怪,这个方法叫做 memoize()。为了使用它,我们首先将想要记忆化的函数包裹在 Underscore 对象内。就像 jQuery 使用美元符号($)进行包装一样,Underscore.js 使用下划线字符(_)。在包装好函数后,我们只需调用 memoize() 方法。以下是完整代码:

**var** fib = _( **function**(n) {
        **return** n < 2 ? n : fib(n-1) + fib(n-2);
    } ).memoize()

正如你所看到的,我们实际上并没有失去函数式编程的可读性或简洁性。而且,在这个实现中引入一个 bug 依然会是一个挑战。唯一真正的变化是性能,它明显更好,如表 8-2 执行时间,续")所示。

表 8-2. fib() 执行时间,续

版本 参数 执行时间(毫秒)
命令式 fib() 28 0.231
函数式 fib() 28 296.9
记忆化 fib() 28 0.352

只需包含 Underscore.js 库并使用它的其中一个方法,我们的函数式实现就几乎与命令式版本的性能相同。

在本章的剩余部分,我们将介绍 Underscore.js 提供的许多其他改进和实用工具。通过对函数式编程的支持,Underscore.js 使得在浏览器中处理数据变得更加容易。

处理数组

如果你的可视化依赖大量数据,那么这些数据很可能存储在数组中。不幸的是,当你处理数组时,很容易陷入命令式编程的陷阱。数组暗示着使用编程循环,而正如我们之前所看到的,编程循环是一种命令式构造,通常会导致错误。如果我们能避免使用循环,转而依赖函数式编程,我们就能提高 JavaScript 的质量。核心的 JavaScript 语言包含一些用于以函数式风格处理数组的工具和方法,但 Underscore.js 增加了许多其他工具。本节将介绍许多 Underscore.js 中最有助于数据可视化的数组工具。

按位置提取元素

如果你只需要数组中的一部分元素来进行可视化,Underscore.js 提供了许多工具,使得提取合适的元素变得简单。在接下来的例子中,我们将考虑一个简单的数组(如 图 8-1)所示。

**var** arr = [1,2,3,4,5,6,7,8,9];

Underscore.js 提供了许多实用工具,使得处理数组变得更加容易。图 8-1. Underscore.js 提供了许多实用工具,使得处理数组变得更加容易。

Underscore.js 的 first() 方法提供了一种简单的方法来提取数组中的第一个元素,或前 n 个元素(参见 图 8-2` 函数返回数组中的第一个元素或前 n 个元素。")):

> _(arr).first()
  1
> _(arr).first(3)
  [1, 2, 3]

first() 函数返回数组中的第一个元素或前 n 个元素。图 8-2. first() 函数返回数组中的第一个元素或前 n 个元素。

请注意,first()(不传递任何参数)返回一个简单的元素,而 first(n) 返回一个元素数组。这意味着,例如,first()first(1) 返回的值是不同的(在例子中分别是 1[1])。

正如你可能预期的那样,Underscore.js 也有一个 last() 方法,用于从数组的末尾提取元素(参见 图 8-3` 函数返回数组中的最后一个元素或最后 n 个元素。"))。

> _(arr).last()
  9
> _(arr).last(3)
  [7, 8, 9]

last() 函数返回数组中的最后一个元素或最后 n 个元素。图 8-3. last() 函数返回数组中的最后一个元素或最后 n 个元素。

如果不传递任何参数,last() 返回数组中的最后一个元素。传递参数 n 时,它返回一个包含原数组中最后 n 个元素的新数组。

这两个函数(.first(3).last(3))的更通用版本在命令式编程风格中可能需要编写一些复杂(且容易出错)的代码。然而,在 Underscore.js 支持的函数式编程风格中,我们的代码简洁且清晰。

如果你想从数组的开头提取元素,但不是通过知道要包含多少个元素来决定,而是知道你想要跳过多少个元素呢?换句话说,你需要“除去最后 n 个”元素。initial()方法可以执行这个提取操作(见图 8-4函数返回数组中除了最后一个元素或除了最后 n 个元素的所有元素。"))。与所有这些方法一样,如果你省略了可选参数,Underscore.js 会假定其值为1`。

> _(arr).initial()
  [1, 2, 3, 4, 5, 6, 7, 8]
> _(arr).initial(3)
  [1, 2, 3, 4, 5, 6]

函数返回数组中除了最后一个元素或除了最后 n 个元素的所有元素。图 8-4. initial()函数返回数组中除了最后一个元素或除了最后 n 个元素的所有元素。

最后,你可能需要initial()的相反操作。rest()方法跳过数组开头的定义数量的元素,返回剩余的元素(见图 8-5`函数返回数组中除了第一个元素或除了前 n 个元素的所有元素。"))。

> _(arr).rest()
  [2, 3, 4, 5, 6, 7, 8, 9]
> _(arr).rest(3)
  [4, 5, 6, 7, 8, 9]

函数返回数组中除了第一个元素或除了前 n 个元素的所有元素。图 8-5. rest()函数返回数组中除了第一个元素或除了前 n 个元素的所有元素。

再次强调,使用传统的命令式编程实现这些功能会很棘手,但在 Underscore.js 中,这些操作非常简单。

合并数组

Underscore.js 包含了一组用于合并两个或更多数组的工具。这些工具包括模拟标准数学集合运算的函数,以及更复杂的合并方式。在接下来的几个例子中,我们将使用两个数组,一个包含前几个斐波那契数,另一个包含前五个偶数(见图 8-6))。

**var** fibs = [0, 1, 1, 2, 3, 5, 8];
**var** even = [0, 2, 4, 6, 8];

Underscore.js 还拥有许多用于处理多个数组的工具。图 8-6. Underscore.js 还拥有许多用于处理多个数组的工具。

union()方法是一个直接的多个数组合并方式。它返回一个包含所有输入元素的数组,并移除任何重复的元素(见图 8-7`函数创建多个数组的并集,移除任何重复项。"))。

> _(fibs).union(even)
  [0, 1, 2, 3, 5, 8, 4, 6]

union() 函数创建多个数组的并集,去除重复元素。图 8-7. union() 函数创建多个数组的并集,去除重复元素。

注意,union() 会去除重复元素,无论它们是出现在不同的输入中(如 028),还是出现在同一个数组中(如 1)。

注意

尽管本章只讨论了两个数组的组合,但大多数 Underscore.js 方法都能接受无限数量的参数。例如,_.union(a,b,c,d,e) 返回五个不同数组的并集。你甚至可以使用 JavaScript 的 apply() 函数来查找数组的数组的并集,方法是像 _.union.prototype.apply(this, arrOfArrs) 这样。

intersection() 方法的作用正如你所预期的,返回所有输入数组中都出现的元素(图 8-8 函数返回多个数组中共有的元素。"))。

> _(fibs).intersection(even)
  [0, 2, 8]

intersection() 函数返回多个数组中共有的元素。图 8-8. intersection() 函数返回多个数组中共有的元素。

difference() 方法是 intersection() 的反操作。它返回那些仅存在于第一个输入数组中、而不在其他输入数组中的元素(图 8-9 函数返回仅存在于多个数组中的第一个数组的元素。"))。

> _(fibs).difference(even)
  [1, 1, 3, 5]

difference() 函数返回仅存在于多个数组中的第一个数组的元素。图 8-9. difference() 函数返回仅存在于多个数组中的第一个数组的元素。

如果你需要消除重复元素,但只有一个数组—这使得 union() 不适用—那么你可以使用 uniq() 方法(图 8-10 函数从数组中去除重复元素。"))。

> _(fibs).uniq()
  [0, 1, 2, 3, 5, 8]

uniq() 函数从数组中去除重复元素。图 8-10. uniq() 函数从数组中去除重复元素。

最后,Underscore.js 还有一个 zip() 方法。它的名字并非来源于流行的压缩算法,而是因为它的作用有点像拉链。它接受多个输入数组,并将它们按元素逐个组合成一个输出数组。该输出是一个数组的数组,其中内层数组是组合后的元素。

> **var** naturals = [1, 2, 3, 4, 5];
> **var** primes = [2, 3, 5, 7, 11];
> _.zip(naturals, primes)
  [ [1,2], [2,3], [3,5], [4,7], [5,11] ]

通过图示理解这一操作可能最为清晰;请参见图 8-11 函数将多个数组的元素配对,组合成一个单一的数组。")。

zip()函数将多个数组中的元素配对成一个单一的数组。图 8-11. zip() 函数将多个数组中的元素配对成一个单一的数组。

这个例子展示了 Underscore.js 的另一种风格。与之前将数组包装在_对象中不同,我们直接在_对象本身上调用zip()方法。在这种情况下,这种替代风格似乎更适合底层功能,但如果你更喜欢_(naturals).zip(prime),你也会得到完全相同的结果。

移除无效数据值

可视化应用程序的一个大问题是无效数据值。虽然我们希望数据源能够确保所有提供的数据都是严格正确的,但遗憾的是,情况往往并非如此。更严重的是,如果 JavaScript 遇到无效值,最常见的结果是一个未处理的异常,这将停止页面上所有后续的 JavaScript 执行。

为了避免出现这种令人不愉快的错误,我们应该验证所有数据集,并在将数据传递给图表或图形库之前移除无效值。Underscore.js 提供了多个工具来帮助完成这项工作。

这些 Underscore.js 方法中最简单的是compact()。此函数会从输入数组中移除 JavaScript 视为false的任何数据值。被移除的值包括布尔值false、数字值0、空字符串以及特殊值NaN(非数字,例如1/0)、undefinednull

> **var** raw = [0, 1, **false**, 2, "", 3, **NaN**, 4, , 5, **null**];
> _(raw).compact()
  [1, 2, 3, 4, 5]

值得强调的是,compact()会移除值为0的元素。如果你使用compact()来清理数据数组,请确保0在你的数据集中不是有效的数据值。

原始数据的另一个常见问题是过度嵌套的数组。如果你想从数据集中消除额外的嵌套层级,可以使用flatten()方法。

> **var** raw = [1, 2, 3, [[4]], 5];
> _(raw).flatten()
  [1, 2, 3, 4, 5]

默认情况下,flatten()会移除所有的嵌套层级,包括多级嵌套。如果你将shallow参数设置为true,它只会移除单一层级的嵌套。

> **var** raw = [1, 2, 3, [[4]], 5];
> _(raw).flatten(**true**)
  [1, 2, 3, [4], 5]

最后,如果你有特定的值想从数组中删除,可以使用without()方法。它的参数提供了一个值的列表,函数应该从输入数组中移除这些值。

> **var** raw = [1, 2, 3, 4];
> _(raw).without(2, 3)
  [1, 4]

在数组中查找元素

JavaScript 一直为字符串定义了indexOf()方法。它返回给定子字符串在更大字符串中的位置。JavaScript 的最新版本已将此方法添加到数组对象中,因此你可以轻松找到数组中给定值的第一次出现。不幸的是,旧版浏览器(特别是 IE8 及更早版本)不支持此方法。

Underscore.js 提供了自己的indexOf()方法,以填补旧浏览器造成的空白。如果 Underscore.js 发现自己运行在支持原生数组indexOf方法的环境中,那么它会调用原生方法,以避免性能上的损失。

> **var** primes = [2, 3, 5, 7, 11];
> _(primes).indexOf(5)
  2

如果你想从数组的中间某个位置开始搜索,可以将起始位置作为第二个参数传递给 indexOf()

> **var** arr = [2, 3, 5, 7, 11, 7, 5, 3, 2];
> _(arr).indexOf(5, 4)
  6

你还可以使用 lastIndexOf() 方法从数组的末尾反向搜索。

> **var** arr = [2, 3, 5, 7, 11, 7, 5, 3, 2];
> _(arr).lastIndexOf(5)
  6

如果你不想从数组的末尾开始,可以将起始索引作为可选参数传入。

Underscore.js 提供了一些对排序数组有帮助的优化。uniq()indexOf() 方法都接受一个可选的布尔参数。如果该参数为 true,那么这两个函数会假设数组是已排序的。这种假设带来的性能提升,对于大数据集来说尤其显著。

该库还包含了一个特殊的 sortedIndex() 函数。该函数同样假设输入的数组是已排序的。它会找到一个位置,在该位置插入特定值,以保持数组的排序顺序。

> **var** arr = [2, 3, 5, 7, 11];
> _(arr).sortedIndex(6)
  3

如果你有一个自定义的排序函数,也可以将其传递给 sortedIndex()

生成数组

我将提到的最后一个数组工具是一个生成数组的便捷方法。range() 方法会告诉 Underscore.js 创建一个具有指定数量元素的数组。你还可以指定一个起始值(默认值为 0)和相邻值之间的增量(默认值为 1)。

> _.range(10)
  [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
> _.range(20,10)
  [20, 21, 22, 23, 24, 25, 26, 27, 28, 29]
> _.range(0, 10, 100)
  [0, 100, 200, 300, 400, 500, 600, 700, 800, 900]

range() 函数在你需要生成与 y 轴值数组匹配的 x 轴值时,非常有用。

> **var** yvalues = [0.1277, 1.2803, 1.7697, 3.1882]
> _.zip(_.range(yvalues.length),yvalues)
  [ [0, 0.1277], [1, 1.2803], [2, 1.7697], [3, 3.1882] ]

在这里,我们使用 range() 来生成匹配的 x 轴值,并使用 zip() 将它们与 y 值组合。

增强对象

尽管前面章节的示例展示了数字数组,但我们可视化的数据通常是 JavaScript 对象,而非简单的数字。如果我们通过 REST 接口获取数据,这种情况尤其常见,因为这些接口几乎总是以 JavaScript 对象表示法(JSON)形式传递数据。如果我们需要增强或转换对象,而不使用命令式的构造,Underscore.js 还提供了另一组工具可以帮助我们。以下示例中,我们可以使用一个简单的 pizza 对象(见 图 8-12)。

**var** pizza = {
    size: 10,
    crust: "thin",
    cheese: **true**,
    toppings: [ "pepperoni","sausage"]
};

Underscore.js 提供了许多用于处理任意 JavaScript 对象的工具。图 8-12. Underscore.js 提供了许多用于处理任意 JavaScript 对象的工具。

操作键和值

Underscore.js 包含了多个方法用于操作构成对象的键和值。例如,keys() 函数会创建一个仅包含对象键的数组(见 图 8-13` 函数将对象的键作为数组返回。"))。

> _(pizza).keys()
  [ "size", "crust", "cheese", "toppings"]]

 函数将对象的键作为数组返回。图 8-13. keys() 函数将对象的键作为数组返回。

类似地,values() 函数创建一个只包含对象值的数组(图 8-14` 函数返回对象的值作为数组"))。

> _(pizza).values()
  [10, "thin", **true**, [ "pepperoni","sausage"]]

values()函数返回对象的值作为数组图 8-14. values() 函数返回对象的值作为数组。

pairs() 函数创建一个二维数组。外层数组的每个元素本身就是一个数组,包含对象的键及其对应的值(图 8-15` 函数将对象转换为数组对"))。

> _(pizza).pairs()
 [
   [ "size",10],
   [ "crust","thin"],
   [ "cheese",**true**],
   [ "toppings",[ "pepperoni","sausage"]]
 ]

pairs()函数将对象转换为数组对图 8-15. pairs() 函数将对象转换为数组对。

要反转这个转换并将数组转换为对象,我们可以使用 object() 函数。

> **var** arr = [ [ "size",10], [ "crust","thin"], [ "cheese",**true**],
            [ "toppings",[ "pepperoni","sausage"]] ]
> _(arr).object()
  { size: 10, crust: "thin", cheese: **true**, toppings: [ "pepperoni","sausage"]}

最后,我们可以使用 invert() 函数交换对象中键和值的角色(图 8-16` 函数交换对象中的键和值"))。

> _(pizza).invert()
  {10: "size", thin: "crust", true: "cheese", "pepperoni,sausage":
"toppings"}

invert()函数交换对象中的键和值图 8-16. invert() 函数交换对象中的键和值。

如前面的示例所示,Underscore.js 甚至可以反转一个对象,即使其值不是简单类型。在这种情况下,它将一个数组 ["pepperoni","sausage"] 转换为值,通过用逗号连接各个数组元素,生成键 "pepperoni,sausage"

还需注意,JavaScript 要求对象的所有键必须是唯一的,但这不一定适用于值。如果一个对象中有多个键具有相同的值,则 invert() 只会保留反转对象中最后一个键。例如,_({key1: value, key2: value}).invert() 返回 {value: key2}

清理对象子集

当你想通过删除不必要的属性来清理对象时,可以使用 Underscore.js 的 pick() 函数。只需传递一个你想保留的属性列表(图 8-17` 函数从对象中选择特定的属性"))。

> _(pizza).pick( "size","crust")
  {size: 10, crust: "thin"}

pick()函数从对象中选择特定属性图 8-17. pick() 函数从对象中选择特定属性。

我们还可以通过使用 omit() 来执行 pick() 的相反操作,并列出我们想删除的属性(图 8-18` 函数从对象中移除属性"))。Underscore.js 会保留对象中的其他所有属性。

> _(pizza).omit( "size","crust")
 {cheese: **true**, toppings: [ "pepperoni","sausage"]}

omit()函数从对象中移除属性。图 8-18. omit()函数从对象中移除属性。

更新属性

在更新对象时,一个常见的需求是确保一个对象包含某些属性,并且这些属性有合适的默认值。Underscore.js 为此目的提供了两个工具。

这两个工具,extend()defaults(),都以一个对象开始,并根据其他对象的属性调整它的属性。如果次要对象包含原始对象缺少的属性,这些工具会将这些属性添加到原始对象中。这些工具的区别在于它们如何处理原始对象中已存在的属性。extend()函数会用新值覆盖原始属性(见图 8-19 函数更新并添加缺失的属性到对象中")):

> **var** standard = { size: 12, crust: "regular", cheese: **true** }
> **var** order = { size: 10, crust: "thin",
  toppings: [ "pepperoni","sausage"] };
> _.extend(standard, order)
  { size: 10, crust: "thin", cheese: **true**,
  toppings: [ "pepperoni","sausage"] };

与此同时,defaults()保持原始属性不变(见图 8-20 函数将缺失的属性添加到对象中")):

> **var** order = { size: 10, crust: "thin",
  toppings: [ "pepperoni","sausage"] };
> **var** standard = { size: 12, crust: "regular", cheese: **true** }
> _.defaults(order, standard)
  { size: 10, crust: "thin",
  toppings [ "pepperoni","sausage"], cheese: **true** };

extend()函数更新并添加缺失的属性到对象中。图 8-19. extend()函数更新并添加缺失的属性到对象中。defaults()函数将缺失的属性添加到对象中。图 8-20. defaults()函数将缺失的属性添加到对象中。

请注意,extend()defaults()都会直接修改原始对象;它们不会创建该对象的副本并返回副本。考虑以下情况:

> **var** order = { size: 10, crust: "thin",
  toppings: [ "pepperoni","sausage"] };
> **var** standard = { size: 12, crust: "regular", cheese: **true** }
> **var** pizza = _.extend(standard, order)
  { size: 10, crust: "thin", cheese: **true**,
  toppings: [ "pepperoni","sausage"] };

这段代码按照预期设置了pizza变量,但它也将standard变量设置为相同的对象。更具体地说,代码通过order中的属性修改了standard,然后将一个新的变量pizza设置为standard。对standard的修改可能并非故意为之。如果你需要以不修改输入参数的方式使用extend()defaults(),可以从一个空对象开始。

> **var** order = { size: 10, crust: "thin",
  toppings: [ "pepperoni","sausage"] };
> **var** standard = { size: 12, crust: "regular", cheese: **true** }
> **var** pizza = _.extend({}, standard, order)
  { size: 10, crust: "thin", cheese: **true**,
  toppings: [ "pepperoni","sausage"] };

这个版本可以获得我们期望的pizza对象,而不会修改standard

操作集合

到目前为止,我们已经看过了各种专门适用于数组或对象的 Underscore.js 工具。接下来,我们将看到一些用于操作集合的工具。在 Underscore.js 中,数组和对象都是集合,因此本节中的工具可以应用于纯数组、纯对象或结合两者的数据结构。在本节中,我们将尝试在一个对象数组上使用这些工具,因为这是我们在数据可视化上下文中最常处理的数据结构。

这是一个小数据集,我们可以在接下来的示例中使用。它包含了 2012 年美国职棒大联盟赛季的一些统计数据。

**var** national_league = [
    { name: "Arizona Diamondbacks",  wins: 81, losses:  81,
      division: "west" },
    { name: "Atlanta Braves",        wins: 94, losses:  68,
      division: "east" },
    { name: "Chicago Cubs",          wins: 61, losses: 101,
      division: "central" },
    { name: "Cincinnati Reds",       wins: 97, losses:  65,
      division: "central" },
    { name: "Colorado Rockies",      wins: 64, losses:  98,
      division: "west" },
    { name: "Houston Astros",        wins: 55, losses: 107,
      division: "central" },
    { name: "Los Angeles Dodgers",   wins: 86, losses:  76,
      division: "west" },
    { name: "Miami Marlins",         wins: 69, losses:  93,
      division: "east" },
    { name: "Milwaukee Brewers",     wins: 83, losses:  79,
      division: "central" },
    { name: "New York Mets",         wins: 74, losses:  88,
      division: "east" },
    { name: "Philadelphia Phillies", wins: 81, losses:  81,
      division: "east" },
    { name: "Pittsburgh Pirates",    wins: 79, losses:  83,
      division: "central" },
    { name: "San Diego Padres",      wins: 76, losses:  86,
      division: "west" },
    { name: "San Francisco Giants",  wins: 94, losses:  68,
      division: "west" },
    { name: "St. Louis Cardinals",   wins: 88, losses:  74,
      division: "central" },
    { name: "Washington Nationals",  wins: 98, losses:  64,
      division: "east" }
];

使用迭代工具

在第一部分,我们看到了一些传统 JavaScript 迭代循环的陷阱以及函数式编程可以提供的改进。我们的斐波那契例子通过使用递归消除了迭代,但许多算法并不适合递归实现。在这些情况下,我们仍然可以通过利用 Underscore.js 中的迭代工具,采用函数式编程风格。

最基本的 Underscore 工具是each()。它在集合中的每个元素上执行一个任意的函数,并且通常作为传统的for (i=0; i<len; i++)循环的直接函数式替代。

> _(national_league).each(**function**(team) { console.log(team.name); })
  Arizona Diamondbacks
  Atlanta Braves
  *// Console output continues...*
  Washington Nationals

如果你熟悉 jQuery 库,你可能知道 jQuery 包括一个类似的$.each()工具。然而,Underscore.js 和 jQuery 版本之间有两个重要的区别。首先,传递给迭代函数的参数在两者之间有所不同。Underscore.js 为数组传递(element, index, list),为简单对象传递(value, key, list),而 jQuery 传递(index, value)。其次,至少在撰写本文时,Underscore.js 的实现可能比 jQuery 版本执行得更快,具体取决于浏览器。(jQuery 也包括一个类似于 Underscore.js 方法的$.map()函数。)

Underscore.js 的map()方法遍历集合,并通过一个任意的函数转换每个元素。它返回一个包含转换后元素的新集合。例如,以下是如何创建一个所有团队胜率的数组:

> _(national_league).map(**function**(team) {
      **return** Math.round(100*team.wins/(team.wins + team.losses);
  })
  [50, 58, 38, 60, 40, 34, 53, 43, 51, 46, 50, 49, 47, 58, 54, 60]

reduce()方法遍历集合并返回一个单一的值。一个参数初始化该值,另一个参数是一个任意的函数,用于更新集合中每个元素的值。例如,我们可以使用reduce()来计算有多少个团队的胜率超过 500。

   > _(national_league).reduce(
➊       **function**(count, team) {
➋           **return** count + (team.wins > team.losses);
         },
➌       0 *// Starting point for reduced value*
     )
     7

如➊处的注释所示,我们从 0 开始计数。该值作为第一个参数传递给➋处的函数,函数返回一个更新后的值,位于➌处。

注意

如果你关注过“大数据”技术的开发,比如 Hadoop 或谷歌的搜索,你可能知道这些技术背后的基本算法是 MapReduce。尽管背景不同,但map()reduce()工具在 Underscore.js 中的原理是相同的。

在集合中查找元素

Underscore.js 有几个方法可以帮助我们在集合中查找元素或元素集合。例如,我们可以使用find()来获取一个超过 90 场胜利的团队。

> _(national_league).find( **function**(team) { **return** team.wins > 90; })
  { name: "Atlanta Braves", wins: 94, losses: 68, division: "east" }

find()函数返回数组中第一个符合标准的元素。要查找所有符合标准的元素,可以使用filter()函数。

> _(national_league).filter( **function**(team) { **return** team.wins > 90; })
  [ { name: "Atlanta Braves", wins: 94, losses: 68, division: "east" },
    { name: "Cincinnati Reds", wins: 97, losses: 65, division: "central" },
    { name: "San Francisco Giants", wins: 94, losses: 68, division: "west" },
    { name: "Washington Nationals", wins: 98, losses: 64, division: "east" }
  ]

filter()函数的反义函数是reject()。它返回一个不符合标准的元素数组。

> _(national_league).reject( **function**(team) { **return** team.wins > 90; })
  [ { name: "Arizona Diamondbacks", wins: 81, losses: 81, division: "west" },
    { name: "Chicago Cubs", wins: 61, losses: 101, division: "central" },
    *// Console output continues...*
    { name: "St. Louis Cardinals", wins: 88, losses: 74, division: "central" }
  ]

如果你的标准可以用属性值来描述,你可以使用filter()的简化版本:where()函数。where()的参数是一组必须匹配的属性,而不是一个用于检查匹配的任意函数。我们可以用它来提取所有属于东区的队伍。

> _(national_league).where({division: "east"})
  [ { name: "Atlanta Braves", wins: 94, losses: 68, division: "east" },
    { name: "Miami Marlins", wins: 69, losses: 93, division: "east" },
    { name: "New York Mets", wins: 74, losses: 88, division: "east" },
    { name: "Philadelphia Phillies", wins: 81, losses: 81, division: "east" },
    { name: "Washington Nationals", wins: 98, losses: 64, division: "east" }
  ]

findWhere()方法将find()的功能与where()的简洁性结合在一起。它返回集合中第一个具有匹配特定值的属性的元素。

> _(national_league).where({name: "Atlanta Braves"})
  {name: "Atlanta Braves", wins: 94, losses: 68, division: "east"}

另一个特别方便的 Underscore.js 工具是pluck()。这个函数通过从集合中提取指定的属性来创建一个数组。例如,我们可以用它来提取一个仅包含队名的数组。

> _(national_league).pluck( "team")
  [
    "Arizona Diamondbacks",
    "Atlanta Braves",
   */* Data continues... */*,
    "Washington Nationals"
  ]

测试集合

有时我们不一定需要转换一个集合;我们只是想检查它的某个方面。Underscore.js 提供了几种工具来帮助进行这些测试。

every()函数告诉我们集合中的所有元素是否通过了一个任意的测试。我们可以用它来检查数据集中每支队伍是否至少有 70 场胜利。

> _(national_league).every(**function**(team) { **return** team.wins >= 70; })
  **false**

也许我们想知道是否有任何队伍至少有 70 场胜利。在这种情况下,any()函数可以提供答案。

> _(national_league).any(**function**(team) { **return** team.wins >= 70; })
  **true**

Underscore.js 还允许我们使用任意函数来查找集合中的最大和最小元素。如果我们的标准是胜场数,我们可以使用max()来找到“最大”队伍。

> _(national_league).max(**function**(team) { **return** team.wins; })
  { name: "Washington Nationals", wins: 98, losses: 64, division: "east" }

不出所料,min()函数的工作方式与此相同。

> _(national_league).min(**function**(team) { **return** team.wins; })
  { name: "Houston Astros", wins: 55, losses: 107, division: "central" }

重排集合

要对集合进行排序,我们可以使用sortBy()方法,并提供一个任意函数来提供可排序的值。下面是如何按胜场数升序重新排列我们的集合。

> _(national_league).sortBy(**function**(team) { **return** team.wins; })
  [ { name: "Houston Astros", wins: 55, losses: 107, division: "central" }
    { name: "Chicago Cubs", wins: 61, losses: 101, division: "central" },
    *// Data continues...*
    { name: "Washington Nationals", wins: 98, losses: 64, division: "east" }

我们还可以通过根据某个属性对元素进行分组来重新组织我们的集合。Underscore.js 在这种情况下提供的函数是groupBy()。一种可能性是按分区重新组织队伍。

> _(national_league).groupBy( "division")
  {
    { west:
      { name: "Arizona Diamondbacks", wins: 81, losses: 81, division: "west" },
      { name: "Colorado Rockies", wins: 64, losses: 98, division: "west" },
      { name: "Los Angeles Dodgers", wins: 86, losses: 76, division: "west" },
      { name: "San Diego Padres", wins: 76, losses: 86, division: "west" },
      { name: "San Francisco Giants", wins: 94, losses: 68, division: "west" },
    },
    { east:
      { name: "Atlanta Braves", wins: 94, losses: 68, division: "east" },
      { name: "Miami Marlins", wins: 69, losses: 93, division: "east" },
      { name: "New York Mets", wins: 74, losses: 88, division: "east" },
      { name: "Philadelphia Phillies", wins: 81, losses: 81,
        division: "east" },
      { name: "Washington Nationals", wins: 98, losses: 64, division: "east" }
    },
    { central:
      { name: "Chicago Cubs", wins: 61, losses: 101, division: "central" },
      { name: "Cincinnati Reds", wins: 97, losses: 65, division: "central" },
      { name: "Houston Astros", wins: 55, losses: 107, division: "central" },
      { name: "Milwaukee Brewers", wins: 83, losses: 79, division: "central" },
      { name: "Pittsburgh Pirates", wins: 79, losses: 83,
        division: "central" },
      { name: "St. Louis Cardinals", wins: 88, losses: 74,
        division: "central" },
    }
  }

我们还可以使用countBy()函数来简单地计算每个组中元素的数量。

> _(national_league).countBy( "division")
  {west: 5, east: 5, central: 6}

注意

虽然我们在groupBy()countBy()中使用了一个属性值("division"),但这两个方法也接受一个任意函数,如果分组的标准不是简单的属性。

作为一个最终技巧,Underscore.js 允许我们使用shuffle()函数随机重新排列一个集合。

_(national_league).shuffle()

总结

尽管本章采用了与书中其余部分不同的方法,但其最终焦点仍然是数据可视化。正如我们在前几章中所见(并且你在自己的项目中肯定也会遇到),我们的可视化所用的原始数据并不总是完美的。我们有时需要通过移除无效值来清理数据,有时则需要重新排列或转换数据,以便它适用于我们的可视化库。

Underscore.js 库包含了丰富的工具和实用程序,帮助完成这些任务。它使我们能够轻松管理数组、修改对象和转换集合。此外,Underscore.js 支持基于函数式编程的底层哲学,因此我们使用 Underscore.js 编写的代码保持高度可读,并能有效抵御错误和缺陷。

第九章:构建数据驱动的网页应用:第一部分

到目前为止,我们已经有机会了解了许多用于创建独立 JavaScript 可视化的工具和库,但我们仅在传统网页的上下文中考虑了它们。当然,今天的 Web 已经不再仅仅是传统网页,尤其在桌面计算机上,网站实际上是功能齐全的软件应用。(即便在移动设备上,许多“应用”实际上也是封装在一个薄外壳中的网站。)当一个网页应用围绕数据进行结构化时,它很可能可以从数据可视化中受益。正是我们将在这个最终项目中考虑的内容:如何将数据可视化集成到真正的网页应用中。

接下来的部分将逐步讲解如何开发一个由数据驱动的示例应用。数据的来源将是 Nike 的 Nike+(nikeplus.com/ 跑步者服务。Nike 销售许多产品和应用,允许跑步者跟踪他们的活动,并保存结果以供分析和回顾。在本章及下一章中,我们将构建一个网页应用,从 Nike 获取数据并呈现给用户。当然,Nike 也有自己的网页应用来查看 Nike+数据,而那个应用远远优于我们这里的简单示例。我们并不是要与 Nike 竞争;我们只是利用 Nike+服务来构建我们的示例。

注意

本示例项目基于本文撰写时的接口版本。此后接口可能已发生变化。

与大多数其他章节不同,本章不会包括多个独立的示例。相反,本章将通过开发和测试单个数据驱动应用的主要阶段来进行讲解。我们将看到如何构建网页应用的基本结构和功能。这包括以下内容:

  • 如何使用框架或库来构建一个网页应用

  • 如何将一个应用程序组织为模型和视图

  • 如何在视图中嵌入可视化内容

在第十章中,我们将专注于一些更细微的细节,处理 Nike+界面的一些特殊情况,并为单页面应用添加一些完善的细节。

注意

要在实际产品中使用 Nike+数据,你必须向 Nike 注册你的应用并获取必要的凭证和安全密钥。这个过程还会让你访问该服务的完整文档,而这些文档并未公开。由于我们在这个示例中并未构建一个真实的应用,因此我们不会涉及这一步。然而,我们将基于 Nike+ API 来构建应用,该 API 在 Nike 的开发者网站上有公开文档(developer.nike.com/index.html)。由于示例中没有包括凭证和安全密钥,它将无法访问真实的 Nike+服务。然而,本书的源代码中确实包含了实际的 Nike+数据,可以用于模拟 Nike+服务进行测试和开发。

框架与库

如果我们使用 JavaScript 为传统网页添加数据可视化,我们不需要太担心如何组织和结构化我们的 JavaScript。毕竟,它通常是一小段代码,尤其是与 HTML 标记和 CSS 样式相比,这些内容也是页面的一部分。然而,对于 Web 应用,代码可能会变得更加庞大和复杂。为了帮助保持代码的组织性和可管理性,我们将利用 JavaScript 应用库,也称为框架

第 1 步:选择一个应用库

决定使用哪个应用库可能比决定使用哪一个更容易。在过去几年里,这些库的数量激增;现在有超过 30 个高质量的库可以选择。一个查看所有替代品的好地方是 TodoMVC (todomvc.com/),它展示了如何在每个库中实现一个简单的待办事项应用。

有一个重要的问题需要问,这个问题可以帮助你缩小选择范围:一个应用库是纯库还是应用框架?这两个术语经常互换使用,但实际上有很大的区别。纯库的功能类似于 jQuery 或本书中我们使用过的其他库。它为我们的应用提供了一组工具,我们可以根据需要使用其中的任意多或任意少的工具。另一方面,应用框架则严格规定了应用的工作方式。我们编写的代码必须遵循框架的结构和约定。根本上,这种区别在于控制权。使用纯库时,我们的代码掌控一切,库只是我们的工具;而使用框架时,框架的代码掌控一切,我们仅仅是添加使应用独特的代码。

纯库的主要优势是灵活性。我们的代码控制着应用程序,我们有充分的自由来根据自己的需求结构化应用程序。然而,这并不总是好事。框架的约束可以保护我们避免做出不良的设计决策。世界上一些最顶尖的 JavaScript 开发人员负责着流行的框架,他们在设计一个好的网页应用程序方面做了大量的思考。框架还有一个好处:因为框架承担了更多的应用程序责任,通常我们需要编写的代码较少。

值得注意的是,框架与纯库之间的区别,但几乎任何网页应用程序都可以通过两者中的任何一种有效构建。两种方法都提供了构建高质量应用程序所需的组织和结构。对于我们的示例,我们将使用 Backbone.js (backbonejs.org/) 库。它迄今为止是最受欢迎的纯(非框架)库,并且被数十个最大的网页站点使用。然而,我们将遵循的一般方法(包括工具如 Yeoman)适用于几乎所有流行的应用程序库。

步骤 2:安装开发工具

当你开始构建第一个真正的网页应用程序时,决定从哪里开始可能会有些让人畏惧。在这个阶段,一个很有帮助的工具是 Yeoman (yeoman.io/),它自称为“现代网页应用的搭建工具”。这个描述相当准确。Yeoman 可以为大量不同的网页应用框架(包括 Backbone.js)定义并初始化项目结构。正如我们所见,它还设置并配置了在应用程序开发过程中需要的其他大部分工具。

在我们能使用 Yeoman 之前,必须先安装 Node.js (nodejs.org/)。Node.js 本身是一个强大的应用程序开发平台,但在这里我们不需要关心其细节。然而,它是许多现代网页开发工具(如 Yeoman)所要求的应用平台。要安装 Node.js,请按照其网站上的说明进行操作 (nodejs.org/).

安装了 Node.js 后,我们可以使用一个命令安装主要的 Yeoman 应用程序以及创建 Backbone.js 应用程序所需的一切 (github.com/yeoman/generator-backbone/)

$ **npm** install -g generator-backbone

你可以在终端应用程序(在 Mac OS X 上)或 Windows 命令提示符下执行此命令。

步骤 3:定义新项目

我们刚刚安装的开发工具将使创建一个新的网页应用项目变得容易。首先,通过以下命令,我们为我们的应用创建一个新的文件夹(命名为running),然后使用cd(更改目录)进入该文件夹。

$ **mkdir** running
$ **cd** running

在该新文件夹中,执行命令yo backbone将初始化项目结构。

$ **yo** backbone

作为初始化的一部分,Yeoman 将请求许可,向 Yeoman 开发者发送诊断信息(主要是我们的应用程序使用了哪些框架和功能)。然后,它会给我们一个选择,是否将一些额外的工具添加到应用程序中。对于我们的示例,我们将跳过任何建议的选项。

**Out** of the box I include HTML5 Boilerplate, jQuery, Backbone.js and Modernizr.
[**?**] What more would you like? (Press **<**space**>** to select)
 > **Bootstrap** for Sass
   **Use** CoffeeScript
   **Use** RequireJs

然后,Yeoman 会进行魔法操作,创建几个子文件夹,安装额外的工具和应用程序,并设置合理的默认值。当你看到所有安装信息在窗口中滚动时,可以高兴地知道 Yeoman 正在为你做所有这些工作。当 Yeoman 完成时,你将拥有一个像图 9-1 中所示的项目结构。虽然它可能不会完全像这里的图示一样,因为自从这段文字写成以来,Web 应用程序可能已经发生了变化,但可以放心,它会遵循最佳实践和规范。

Yeoman 为 Web 应用程序创建一个默认的项目结构。图 9-1. Yeoman 为 Web 应用程序创建一个默认的项目结构。

在接下来的章节中,我们将花更多时间处理这些文件和文件夹,但这里是 Yeoman 为我们设置的项目的快速概览。

  • app/。一个包含我们应用程序所有代码的文件夹

  • bower.json。一个用于跟踪我们的应用程序使用的所有第三方库的文件

  • gruntfile.js。一个控制如何测试和构建我们应用程序的文件

  • node_modules/。一个包含构建和测试我们应用程序所需工具的文件夹

  • package.json。一个标识用于构建和测试我们应用程序的工具的文件

  • test/。一个包含我们为测试应用程序而编写代码的文件夹

此时,Yeoman 已经设置了一个完整的 Web 应用程序(尽管它没有任何功能)。你可以从命令提示符执行grunt serve命令,在浏览器中查看它。

$ **grunt** serve
**Running** "serve" task

**Running** "clean:server" (clean) **task**

**Running** "createDefaultTemplate" task

**Running** "jst:compile" (jst) **task**
**>>** **Destination** not written because compiled files were empty.

**Running** "connect:livereload" (connect) **task**
**Started** connect web server on http://localhost:9000

**Running** "open:server" (open) **task**

**Running** "watch:livereload" (watch) **task**
**Waiting...**

grunt命令运行 Yeoman 包中的一个工具。传递serve选项时,它会清理应用程序文件夹,启动一个 Web 服务器来托管应用程序,打开一个 Web 浏览器并导航到骨架应用程序。你将在浏览器中看到类似于图 9-2 的内容。

默认的 Yeoman Web 应用程序在浏览器中运行。图 9-2. 默认的 Yeoman Web 应用程序在浏览器中运行。

恭喜!我们的 Web 应用程序,虽然非常基础,但现在已经在运行了。

第 4 步:添加我们独特的依赖项

Yeoman 为新应用程序设置了合理的默认设置和工具,但我们的应用程序需要一些不在默认设置中的 JavaScript 库,例如用于地图的 Leaflet 和用于图表的 Flot。用于处理日期和时间的 Moment.js (momentjs.com/) 库也会派上用场,Underscore.string (epeli.github.io/underscore.string/) 库也是如此。我们可以通过一些简单的命令将这些库添加到我们的项目中。--save 选项会告诉 bower 工具(它是 Yeoman 包的一部分)记住我们的项目依赖这些库。

$ **bower** install leaflet --save
$ **bower** install flot --save
$ **bower** install momentjs --save
$ **bower** install underscore.string --save

也许你已经开始体会到像 Yeoman 这样的工具如何简化开发。这里展示的简单命令让我们免去了在网上寻找库、下载相应的文件、将它们复制到项目中的正确位置等繁琐工作。

更重要的是,Yeoman(严格来说是 bower 工具)会自动处理这些库所依赖的任何其他库。例如,Flot 库依赖 jQuery。当 Yeoman 安装 Flot 时,它还会检查并确保 jQuery 已安装在项目中。在我们的例子中,它已经安装,因为 Backbone.js 依赖于它,但如果 jQuery 尚未安装,Yeoman 会自动找到并安装它。

对于大多数库,bower 可以完全安装所有必要的组件和文件。然而,对于 Leaflet,我们需要执行一些额外的步骤。将目录更改为 leaflet 文件夹,该文件夹位于 app/bower_components 中。从那里运行两个命令,安装 Leaflet 所需的独特工具:

$ **npm** install
$ **npm** install jake -g

执行命令 jake 将运行所有 Leaflet 的测试,如果通过测试,将为我们的应用程序创建一个 Leaflet.js 库。

$ **jake**
**Checking** for JS errors...
    **Check** passed.

**Checking** for specs JS errors...
    **Check** passed.

**Running** tests...

**...............................................................................**
**...............................................................................**
**...............................................................................**
**........................................**
**PhantomJS** 1.9.7 (Mac OS X)**:** Executed 280 of 280 SUCCESS (0.881 secs / 0.496 secs)
    **Tests** ran successfully.

**Concatenating** and compressing 75 files...
    **Uncompressed**: 217.22 KB (unchanged)
    **Compressed**: 122.27 KB (unchanged)
    **Gzipped**: 32.71 KB

剩下的就是将其他库添加到我们的 HTML 文件中。这很简单。我们应用程序的主页是 index.html,位于 app 文件夹中。已经有一块包含 jQuery、Underscore.js 和 Backbone.js 的代码:

*<!-- build:js scripts/vendor.js -->*
**<script** src="bower_components/jquery/dist/jquery.js"**></script>**
**<script** src="bower_components/underscore/underscore.js"**></script>**
**<script** src="bower_components/backbone/backbone.js"**></script>**
*<!-- endbuild -->*

我们可以在 Backbone.js 后添加我们的新库。

*<!-- build:js scripts/vendor.js -->*
**<script** src="bower_components/jquery/dist/jquery.js"**></script>**
**<script** src="bower_components/underscore/underscore.js"**></script>**
**<script** src="bower_components/backbone/backbone.js"**></script>**
**<script** src="bower_components/flot/jquery.flot.js"**></script>**
**<script** src="bower_components/leaflet/dist/leaflet-src.js"**></script>**
**<script** src="bower_components/momentjs/moment.js"**></script>**
**<script**
    src="bower_components/underscore.string/lib/underscore.string.js"**>**
**</script>**
*<!-- endbuild -->*

正如我们在 第六章 中看到的,Leaflet 还需要自己的样式表。我们将其添加到 index.html 文件的顶部,位于 main.css 之前。

*<!-- build:css(.tmp) styles/main.css -->*
**<link** rel="stylesheet" href="bower_components/leaflet/dist/leaflet.css"**>**
**<link** rel="stylesheet" href="styles/main.css"**>**
*<!-- endbuild -->*

现在我们已经设置了应用程序的结构并安装了必要的库,是时候开始开发了。

模型和视图

有许多适用于 Web 应用的应用程序库,每个库都有其独特之处,但大多数库在应用架构的关键原则上是达成共识的。也许最基本的原则是将模型视图分开。用于跟踪应用核心数据(模型)的代码应该与展示这些数据给用户(视图)的代码分开。强制这种分离可以更容易地更新和修改它们。如果你想用表格而不是图表来展示数据,你可以在不改变模型的情况下做到这一点。如果你需要将数据源从本地文件更改为 REST API,也可以在不修改视图的情况下做到这一点。在本书中,我们一直以非正式的方式运用这一原则。在所有示例中,我们都将获取和格式化数据的步骤与可视化数据的步骤隔离开来。使用像 Backbone.js 这样的应用库为我们提供了更明确管理模型和视图的工具。

步骤 1:定义应用的模型

我们的运行应用被设计为与 Nike+ 配合使用,Nike+ 提供关于跑步的详细信息——训练跑、间歇训练、越野跑、比赛等。我们想要的数据集只包含跑步,因此我们应用的核心模型自然是跑步。

Yeoman 工具使得为我们的应用定义模型变得非常简单。一个简单的命令就可以定义一个新模型,并为该模型创建 JavaScript 文件和脚手架。

$ **yo** backbone:model run
   **create** app/scripts/models/run.js
   **invoke**   backbone-mocha:model
   **create**     test/models/run.spec.js

该命令会创建两个新文件:在 app/scripts/models/ 文件夹中的 run.js 和在 test/ 文件夹中的 run.spec.js。让我们来看看 Yeoman 为我们的模型创建的文件。它相当简短。

➊ */*Global Running, Backbone*/*

➋ Running.Models = Running.Models || {};

   (**function** () {
       "use strict";
       Running.Models.Run = Backbone.Model.extend({
           url: "",
           initialize: **function**() {
           },
           defaults: {
           },
           validate: **function**(attrs, options) {
           },
           parse: **function**(response, options) {
               **return** response;
           }
       });
   })();

在 ➊ 处是一个注释,列出了我们的模型所需的全局变量。在这种情况下,只有两个变量:Running(即我们的应用)和 Backbone。接下来,在 ➋ 处,如果 Running 对象尚未具有 .Models 属性,文件会创建该属性。

当浏览器遇到这一行时,它会检查 Running.Models 是否存在。如果存在,那么 Running.Models 就不会是 false,浏览器就不需要考虑逻辑||)的第二个条件。该语句只是将 Running.Models 赋值给它自己,因此没有实际效果。然而,如果 Running.Models 不存在,那么它会被评估为 false,浏览器会继续执行第二个条件,并将一个空对象({})赋值给 Running.Models。最终,这条语句确保了 Running.Models 对象的存在。

文件中的其余代码被封装在一个立即执行的函数表达式中。如果你之前没有见过这种模式,它可能看起来有点奇怪。

(**function** () {
    */* Code goes here */*
})();

然而,如果我们将这段代码重写成一行,它可能会更容易理解。

( **function** () { */* Code goes here */* } ) ();

该语句定义了一个 JavaScript 函数,使用函数表达式function () { /* ... */ },然后通过结尾的()调用(技术上是调用)这个新创建的函数。因此,我们真正做的事情就是把代码放进一个函数里,并调用该函数。在专业的 JavaScript 中,你会经常看到这种模式,因为它可以保护一段代码不与应用程序中的其他代码块相互干扰。

当你在 JavaScript 中定义一个变量时,它是一个全局变量,代码中的任何地方都可以访问。因此,如果两段不同的代码尝试定义相同的全局变量,这些定义就会发生冲突。这种交互可能会导致很难发现的 BUG,因为一段代码无意中干扰了完全不同的另一段代码。为了防止这个问题,我们可以避免使用全局变量,而在 JavaScript 中避免使用全局变量的最简单方法就是将变量定义在函数内部。这就是立即调用函数表达式的目的。它确保我们代码中定义的任何变量都是局部的,而不是全局的,并且避免了不同代码块之间的相互干扰。

步骤 2:实现模型

我们的应用实际上只需要这一模型,它已经是完整的了!没错:Yeoman 为我们设置的脚手架是一个完整且功能齐全的运行模型。事实上,如果不是 Nike 的 REST API 存在一些特殊情况,我们根本不需要修改模型代码。我们将在第十章中讨论这些特殊情况。

然而,在继续下一步之前,让我们看一下我们新创建的模型可以做些什么。为此,我们将在模型代码中临时添加一些内容。我们在最终的应用中不会使用以下代码;它只是用来展示我们的模型已经可以做些什么。

首先,让我们添加一个 URL 来检索跑步的详细信息(Nike+使用更通用的术语活动)。从 Nike+文档中,我们发现这个 URL 是api.nike.com/v1/me/sport/activities/<activityId>

   Running.Models.Run = Backbone.Model.extend({
➊     url: "https://api.nike.com/v1/me/sport/activities/",
       initialize: **function**() {
       },
       defaults: {
       },
       validate: **function**(attrs, options) {
       },
       parse: **function**(response, options) {
           **return** response;
       }
   });

URL 的最后部分取决于具体的活动,因此这里我们只将 URL 的一般部分添加到我们的模型中(➊)。

现在假设我们想从 Nike+服务中获取特定跑步的详细信息。该跑步的唯一标识符是2126456911。如果 Nike+ API 遵循典型的约定,我们可以使用以下假设的两个语句,创建一个代表该跑步的变量,并获取它的所有数据。(我们将在连接 Nike+服务的第 7 步中考虑实际 Nike+接口的特殊情况。)

**var** run = **new** Running.Models.Run({id: 2126456911});
run.fetch();

由于许多 API 确实遵循典型的约定,因此花些时间理解这些代码的工作方式是值得的。第一条语句创建一个新的 Run 模型实例,并指定其标识符。第二条语句告诉 Backbone 从服务器检索模型的数据。Backbone 将处理所有与 Nike+的通信,包括错误处理、超时、解析响应等。一旦获取完成,来自该跑步的详细信息将可以从模型中获取。如果我们提供一个回调函数,我们可以输出一些细节。这里是一个示例:

**var** run = **new** Running.Models.Run({id: 2126456911});
run.fetch({success: **function**() {
    console.log("Run started at ", run.get("startTime"));
    console.log("    Duration: ",  run.get("metricSummary").duration);
    console.log("    Distance: ",  run.get("metricSummary").distance);
    console.log("    Calories: ",  run.get("metricSummary").calories);
}});

浏览器控制台中的输出将是以下内容:

**Run** started at 2013-04-09T10:54:33Z
    **Duration**: 0:22:39.000
    **Distance**: 3.7524
    **Calories**: 240

这几行简单的代码还不错!不过,这一步中的代码其实只是绕道而行。我们的应用程序不会以这种方式使用单独的模型。相反,我们将使用一个更强大的 Backbone.js 功能:集合。

第 3 步:定义应用程序的集合

我们创建的模型是用来捕获单次跑步的数据。然而,我们的用户并不只对单次跑步感兴趣。他们希望看到所有的跑步记录——几十个、上百个,甚至可能成千上万。我们可以通过一个集合(或模型的组)来处理所有这些跑步记录。集合是 Backbone.js 的核心概念之一,它将大大有助于我们的应用程序。让我们定义一个集合来存储所有用户的跑步记录。

Yeoman 使得定义和设置集合的脚手架变得非常容易。我们只需在命令行执行单个命令yo backbone:collection runs。(是的,我们非常有创意地将我们的跑步集合命名为runs。)

$ **yo** backbone:collection runs
   **create** app/scripts/collections/runs.js
   **invoke**   backbone-mocha:collection
   **create**     test/collections/runs.spec.js

Yeoman 对集合的处理与它对模型的处理相同:它创建一个实现文件(runs.js,位于app/scripts/collections/文件夹中)和一个测试文件。现在,让我们看看runs.js

*/*Global Running, Backbone*/*

Running.Collections = Running.Collections || {};

(**function** () {
    "use strict";
    Running.Collections.Runs = Backbone.Collection.extend({
        model: Running.Models.Runs
    });
})();

这个文件比我们的模型还简单;默认的集合只有一个属性,用来指示集合包含的模型类型。不幸的是,Yeoman 并不够智能,不能处理复数形式,因此它假设模型的名称与集合的名称相同。对于我们的应用程序来说,这并不成立,因为我们的模型是 Run(单数),而集合是 Runs(复数)。在我们去掉那个s的同时,我们还可以添加一个属性来指定集合的 REST API。这是 Nike+服务的一个 URL。

Running.Collections.Runs = Backbone.Collection.extend({
    url: "https://api.nike.com/v1/me/sport/activities/",
    model: Running.Models.Run
});

通过这两个小改动,我们已经准备好利用我们的新集合(除了处理一些 Nike+ API 的特殊情况;我们暂时忽略这个复杂性,稍后再处理)。我们需要做的就是创建一个新的 Runs 集合实例,然后获取其数据。

**var** runs = **new** Running.Collections.Runs();
runs.fetch();

构建一个包含用户跑步数据的集合只需要这么简单。Backbone.js 为每个跑步数据创建一个模型,并从服务器中检索该模型的数据。更好的是,这些跑步模型存储在一个真正的 Underscore.js 集合中,这使我们能够使用许多强大的方法来操作和搜索集合。比如,假设我们想要找出用户所有跑步的总距离,这正是 Underscore.js 的reduce()函数的应用场景。

**var** totalDistance = runs.reduce( **function**(sum, run) {
    **return** sum + run.get("metricSummary").distance;
}, 0);

例如,这段代码可以告诉我们,用户在 Nike+ 上总共跑了 3,358 公里。

注意

正如你可能已经注意到的,我们在 Backbone.js 应用中利用了许多来自 Underscore.js 的工具。这并非巧合。Jeremy Ashkenas 是这两个项目的首席开发者。

步骤 4:定义应用程序的主视图

现在我们已经有了用户的所有跑步数据,接下来是展示这些数据。我们将通过 Backbone.js 的 views 来完成这一操作。为了简化示例,我们只考虑两种展示跑步数据的方式。首先,我们将显示一个表格,列出每个跑步的总结信息。然后,如果用户点击表格中的某一行,我们将展示该跑步的详细信息,包括任何可视化内容。我们的应用程序的主视图将是总结表格,因此我们首先集中关注这个部分。

一个 Backbone.js 视图负责将数据呈现给用户,这些数据可能保存在集合或模型中。对于我们应用程序的主页面,我们希望展示所有用户跑步的总结信息。因此,这个视图是整个集合的视图。我们将称这个视图为 Summary

对于这个总结视图,表格的主体将是一系列表格行,每行展示有关单个跑步的数据总结。这意味着我们可以简单地创建一个展示单个跑步模型的视图,作为表格行,并将我们的主总结视图设计为主要由多个 SummaryRow 视图组成。我们可以再次依赖 Yeoman 来设置这两种视图的脚手架。

$ **yo** backbone:view summary
   **create** app/scripts/templates/summary.ejs
   **create** app/scripts/views/summary.js
   **invoke**   backbone-mocha:view
   **create**     test/views/summary.spec.js
$ **yo** backbone:view summaryRow
   **create** app/scripts/templates/summaryRow.ejs
   **create** app/scripts/views/summaryRow.js
   **invoke**   backbone-mocha:view
   **create**     test/views/summaryRow.spec.js

Yeoman 设置的脚手架对每个视图几乎都是一样的;只有名称不同。以下是一个 Summary 视图的示例。

*/*Global Running, Backbone, JST*/*

Running.Views = Running.Views || {};

(**function** () {
    "use strict";
    Running.Views.Summary = Backbone.View.extend({
        template: JST["app/scripts/templates/summary.ejs"],
        tagName: "div",
        id: "",
        className: "",
        events: {},
        initialize: **function** () {
            **this**.listenTo(**this**.model, "change", **this**.render);
        },
        render: **function** () {
            **this**.$el.html(**this**.template(**this**.model.toJSON()));
        }
    });
})();

文件的整体结构与我们的模型和集合相同,但在视图本身,内容要复杂一些。让我们逐一查看视图的属性。第一个属性是template。这是我们定义视图的确切 HTML 标记的地方,接下来我们会更详细地看看这一部分。

tagName 属性定义了我们的视图将使用的 HTML 标签作为其父级。Yeoman 默认为 <div>,但我们知道在我们的案例中,它将是 <table>。我们稍后会进行更改。

idclassName 属性指定要添加到主容器(在我们案例中是 <table>)的 HTML id 属性或 class 值。例如,我们可以根据这些值来设置一些 CSS 样式。对于我们的示例,暂时不考虑样式,所以我们可以将这两个属性留空或完全删除。

接下来是 events 属性。该属性用于标识与视图相关的用户事件(如鼠标点击)。对于 Summary 视图而言,没有任何事件,因此我们可以将该对象留空或直接删除。

最后两个属性,initialize()render(),都是方法。在我们考虑这两个方法之前,让我们先看看在进行刚才提到的调整后,Summary 视图的效果。现在,我们已经去掉了不需要的属性,剩下的只有 templatetagName 属性,以及 initialize()render() 方法:

Running.Views.Summary = Backbone.View.extend({
    template: JST["app/scripts/templates/summary.ejs"],
    tagName: "table",
    initialize: **function** () {
            **this**.listenTo(**this**.model, "change", **this**.render);
    },
    render: **function** () {
            **this**.$el.html(**this**.template(**this**.model.toJSON()));
    }
});

现在让我们来看一下最后两个方法,首先是 initialize()。这个方法只有一个语句(除了我们刚才添加的 return 语句)。通过调用 listenTo(),它告诉 Backbone.js 该视图希望监听事件。第一个参数 this.collection 指定了事件的目标,意思是该视图想要监听影响集合的事件。第二个参数指定了事件的类型。在这里,视图希望在集合发生变化时获知。最后一个参数是事件发生时 Backbone.js 应该调用的函数。每次 Runs 集合变化时,我们希望 Backbone.js 调用视图的 render() 方法。这是合理的,因为每当 Runs 集合发生变化时,页面上展示的内容就会过时。为了保持最新,视图应刷新其内容。

视图的大部分实际工作都发生在其 render() 方法中。毕竟,这段代码实际上是为网页创建 HTML 标记的。Yeoman 已经为我们提供了一个模板,但对于集合视图来说,这还不够。模板负责整个集合的 HTML,但它并没有处理集合中各个模型的 HTML。对于单个的运行,我们可以使用 Underscore.js 的 each() 函数来迭代集合并渲染每个运行。

从以下代码可以看出,我们还在每个方法中添加了 return this; 语句。稍后我们将利用这一点来链式调用多个方法,从而在一个简洁的语句中完成。

Running.Views.Summary = Backbone.View.extend({
    template: JST["app/scripts/templates/summary.ejs"],
    tagName: "table",
    initialize: **function** () {
        **this**.listenTo(**this**.collection, "change", **this**.render);
        **return** **this**;
    },
     render: **function** () {
        **this**.$el.html(**this**.template());
        **this**.collection.each(**this**.renderRun, **this**);
        **return** **this**;
    }
});

现在我们需要编写 renderRun() 方法来处理每个单独的运行。以下是我们希望该方法执行的内容:

  1. 为该运行创建一个新的 SummaryRow 视图。

  2. 渲染那个 SummaryRow 视图。

  3. 将生成的 HTML 附加到 Summary 视图中的 <tbody> 元素。

实现这些步骤的代码是直接的,但逐步处理每个步骤会更有帮助。

  1. 创建一个新的 SummaryRow 视图:new SummaryRow({model: run})

  2. 渲染那个 SummaryRow 视图:.render()

  3. 追加结果:this.$("tbody").append();

当我们将这些步骤组合在一起时,我们得到了renderRun()方法。

renderRun: **function** (run) {
    **this**.$("tbody").append(**new** Running.Views.SummaryRow({
        model: run
    }).render().el);
}

我们对 Summary 视图所做的大多数更改同样适用于 SummaryRow 视图,尽管我们不需要在render()方法中添加任何内容。以下是我们对 SummaryRow 的第一次实现。请注意,我们已将tagName属性设置为"tr",因为我们希望每个运行模型作为表格行展示。

Running.Views.SummaryRow = Backbone.View.extend({
    template: JST["app/scripts/templates/summaryRow.ejs"],
    tagName: "tr",
    events: {},
    initialize: **function** () {
        **this**.listenTo(**this**.model, "change", **this**.render);
        **return** **this**;
    },
    render: **function** () {
        **this**.$el.html(**this**.template(**this**.model.toJSON()));
        **return** **this**;
    }
});

现在我们拥有了所有需要的 JavaScript 代码来显示我们应用程序的主要摘要视图。

第 5 步:定义主要视图模板

到目前为止,我们已经开发了 JavaScript 代码来操作我们的 Summary 和 SummaryRow 视图。但这些代码并没有生成实际的 HTML 标记。为了完成这个任务,我们依赖于模板。模板是带有占位符的骨架 HTML 标记,用于填充具体值。将 HTML 标记限制在模板中有助于保持我们的 JavaScript 代码清晰、结构良好且易于维护。

正如有许多流行的 JavaScript 应用程序库一样,也有许多模板语言。然而,我们的应用程序不需要任何复杂的模板功能,因此我们将继续使用 Yeoman 为我们设置的默认模板处理过程。该过程依赖于 JST 工具(github.com/gruntjs/grunt-contrib-jst/来处理模板,且该工具使用 Underscore.js 模板语言(underscorejs.org/#template/)。通过一个示例,容易看出这个过程是如何工作的,因此让我们深入了解一下。

我们将处理的第一个模板是 SummaryRow 的模板。在我们的视图中,我们已经确定 SummaryRow 是一个<tr>元素,因此模板只需要提供该<tr>内的内容。我们将从关联的 Run 模型中获取这些内容,而该模型又来自 Nike+服务。以下是 Nike+可能返回的一个示例活动。

{
    "activityId": "2126456911",
    "activityType": "RUN",
    "startTime": "2013-04-09T10:54:33Z",
    "activityTimeZone": "GMT-04:00",
    "status": "COMPLETE",
    "deviceType": "IPOD",
    "metricSummary": {
        "calories": 240,
        "fuel": 790,
        "distance": 3.7524,
        "steps": 0,
        "duration": "0:22:39.000"
    },
    "tags": [*/* Data continues... */*],
    "metrics": [*/* Data continues... */*],
    "gps": {*/* Data continues... */*}
}

对于首次实现,我们展示运行的时间,以及其持续时间、距离和卡路里。因此,我们的表格行将有四个单元格,每个单元格包含一个这些值。我们可以在app/scripts/templates文件夹中找到模板summaryRow.ejs。默认情况下,Yeoman 将其设置为一个简单的段落。

**<p>**Your content here.**</p>**

让我们用四个表格单元格来替换它。

**<td></td>**
**<td></td>**
**<td></td>**
**<td></td>**

作为单元格内容的占位符,我们可以使用包含在特殊<%=%>分隔符中的模型属性。完整的 SummaryRow 模板如下。

**<td>**<%= startTime %>**</td>**
**<td>**<%= metricSummary.duration %>**</td>**
**<td>**<%= metricSummary.distance %>**</td>**
**<td>**<%= metricSummary.calories %>**</td>**

另一个我们需要提供的模板是 Summary 模板。由于我们已经将视图的主要标签设置为<table>,因此这个模板应该指定该<table>中的内容:一个表头行加上一个空的<tbody>元素(其中的单独行将来自 Run 模型)。

**<thead>**
    **<tr>**
        **<th>**Time**</th>**
        **<th>**Duration**</th>**
        **<th>**Distance**</th>**
        **<th>**Calories**</th>**
    **</tr>**
**</thead>**
**<tbody></tbody>**

现在我们终于准备好为我们的运行构建主要视图了。步骤非常直接:

  1. 创建一个新的 Runs 集合。

  2. 从服务器获取该集合的数据。

  3. 为该集合创建一个新的 Summary 视图。

  4. 渲染视图。

以下是实现这四个步骤的 JavaScript 代码:

**var** runs = **new** Running.Collection.Runs();
runs.fetch();
**var** summaryView = **new** Running.Views.Summary({collection: runs});
summaryView.render();

我们可以通过视图的 el元素)属性访问构建好的 <table>。它看起来大致如下:

**<table>**
  **<thead>**
    **<tr>**
        **<th>**Time**</th>**
        **<th>**Duration**</th>**
        **<th>**Distance**</th>**
        **<th>**Calories**</th>**
    **</tr>**
  **</thead>**
  **<tbody>**
    **<tr>**
        **<td>**2013-04-09T10:54:33Z**</td>**
        **<td>**0:22:39.000**</td>**
        **<td>**3.7524**</td>**
        **<td>**240**</td>**
    **</tr>**
    **<tr>**
        **<td>**2013-04-07T12:34:40Z**</td>**
        **<td>**0:44:59.000**</td>**
        **<td>**8.1724**</td>**
        **<td>**569**</td>**
    **</tr>**
    **<tr>**
        **<td>**2013-04-06T13:28:36Z**</td>**
        **<td>**1:28:59.000**</td>**
        **<td>**16.068001**</td>**
        **<td>**1200**</td>**
    **</tr>**
  **</tbody>**
**</table>**

当我们将这些标记插入页面时,用户可以看到一个简单的总结表格,列出他们的跑步记录,如 图 9-3 所示。

一个包含跑步信息总结的简单表格图 9-3. 一个包含跑步信息总结的简单表格

步骤 6:优化主视图

现在我们开始有了些进展,尽管表格内容仍然需要调整。毕竟,跑步距离 16.068001 公里中的最后一位数字真的重要吗?由于 Nike+ 决定了我们 Run 模型的属性,可能看起来我们无法控制传递给模板的值。幸运的是,事实并非如此。如果我们查看 SummaryView 的 render() 方法,我们可以看到模板是如何获得这些值的。

render: **function** () {
    **this**.$el.html(**this**.template(**this**.model.toJSON()));
    **return** **this**;
}

模板的值来自我们直接从模型创建的 JavaScript 对象。Backbone.js 提供了 toJSON() 方法,它返回一个对应于模型属性的 JavaScript 对象。实际上,我们可以将任何 JavaScript 对象传递给模板,甚至是我们在 render() 方法中自己创建的对象。让我们重新编写这个方法,以提供一个更友好的 Summary 视图。我们将逐个获取模型的属性。

第一个是跑步的日期。像 “2013-04-09T10:54:33Z” 这样的日期对于普通用户来说并不易读,而且可能并不是他们所在的时区。处理日期和时间实际上是很棘手的,但优秀的 Moment.js 库 (momentjs.com/ ) 可以处理所有复杂的操作。因为我们在前面的章节中已经将该库添加到应用程序中,现在可以利用它了。

render: **function** () {
    **var** run = {};
    run.date = moment(**this**.model.get("startTime")).calendar();

注意

为了简洁起见,我们在前面的代码中做了一些简化,它将 UTC 时间戳转换为浏览器的本地时区。实际上,将其转换为跑步记录的时区可能更为准确,而这个时区在 Nike+ 的数据中是提供的。

接下来是跑步的持续时间。我们不太需要显示 Nike+ 所包含的秒数的分数部分,所以我们直接从属性中去掉它们。(当然,四舍五入会更精确,但假设我们的用户不是正在训练的奥运运动员,一秒钟的差异其实不重要。而且,Nike+ 好像总是把这些小于秒的时间段记录为 ".000")

run.duration = **this**.model.get("metricSummary").duration.split(".")[0];

distance 属性也可以做些调整。除了将其四舍五入到合理的小数位数外,我们还可以为美国用户将其从公里转换为英里。一条语句就能同时解决这两个问题。

run.distance = Math.round(62. *
    **this**.model.get("metricSummary").distance)/100 +
    " Miles";

calories 属性保持原样,所以我们只需将其复制到临时对象中。

run.calories = **this**.model.get("metricSummary").calories;

最后,如果你是一个热衷的跑步者,可能已经注意到 Nike+ 属性中缺少一个重要的值:每英里分钟的平均配速。我们有数据来计算它,所以让我们也将其添加进去。

**var** secs = _(run.duration.split(":")).reduce(**function**(sum, num) {
    **return** sum*60+parseInt(num,10); }, 0);
**var** pace = moment.duration(1000*secs/parseFloat(run.distance));
run.pace = pace.minutes() + ":" + _(pace.seconds()).pad(2, "0");

现在我们有了一个新的对象,可以传递给模板。

**this**.$el.html(**this**.template(run));

我们还需要修改两个模板以匹配新的标记。以下是更新后的 SummaryRows 模板。

**<td>**<%= date %>**</td>**
**<td>**<%= duration %>**</td>**
**<td>**<%= distance %>**</td>**
**<td>**<%= calories %>**</td>**
**<td>**<%= pace %>**</td>**

这是包含 Pace 额外列的 Summary 模板。

**<thead>**
  **<tr>**
    **<th>**Date**</th>**
    **<th>**Duration**</th>**
    **<th>**Distance**</th>**
    **<th>**Calories**</th>**
    **<th>**Pace**</th>**
  **</tr>**
**</thead>**
**<tbody></tbody>**

现在我们为用户提供了一个大大改进的总结表格,见图 9-4。

改进后的总结表格,数据看起来更清晰图 9-4。改进后的总结表格,数据看起来更清晰

可视化视图

现在我们已经看到了如何使用 Backbone.js 视图将数据与其展示分离,我们可以考虑如何使用相同的方法来处理数据可视化。当展示是简单的 HTML 标记时——就像前一节中的表格——使用模板查看模型非常容易。但模板不足以处理数据可视化,所以我们需要修改方法来适应这种情况。

Nike+ 服务的数据为可视化提供了很多机会。例如,每次跑步可能包括记录用户的心率、即时配速和每 10 秒记录一次的累计距离。跑步数据还可能包括每秒捕捉的用户 GPS 坐标。这类数据非常适合做图表和地图,在这一节中,我们将同时将这两种可视化添加到我们的应用程序中。

步骤 1:定义额外视图

正如我们在前一节中所做的那样,我们将依赖 Yeoman 来创建额外视图的脚手架。其中一个视图,我们称之为详情,将作为单个运行详细信息的总体视图。在该视图内,我们将创建三个额外视图,每个视图展示运行的不同方面。我们可以将这些视图视为一个层次结构。

  • 详情。单次跑步的详细视图

  • 属性。与运行相关的完整属性集

  • 图表。展示运行期间表现的图表

  • 地图。跑步路线的地图

为了开始开发这些视图,我们返回命令行并执行四个 Yeoman 命令。

$ **yo** backbone:view details
$ **yo** backbone:view properties
$ **yo** backbone:view charts
$ **yo** backbone:view map

步骤 2:实现详情视图

详情视图实际上不过是其三个子视图的容器,因此它的实现非常简单。我们为每个子视图创建一个新的视图,渲染该视图,并将生成的标记添加到详情视图中。以下是该视图的完整代码:

Running.Views.Details = Backbone.View.extend({
    render: **function** () {
        **this**.$el.empty();
        **this**.$el.append(
            **new** Running.Views.Properties({model: **this**.model}).render().el
        );
        **this**.$el.append(
            **new** Running.Views.Charts({model: **this**.model}).render().el
        );
        **this**.$el.append(
            **new** Running.Views.Map({model: **this**.model}).render().el
        );
        **return** **this**;
    }
});

与我们之前创建的视图不同,这个视图没有 initialize() 方法。这是因为 Details 视图不需要监听模型的变化,因此在初始化时不需要做任何事情。换句话说,Details 视图本身实际上并不依赖于 Run 模型的任何属性。(另一方面,子视图在很大程度上依赖这些属性。)

render() 方法本身首先清除其元素中的任何现有内容。这一行确保可以安全地多次调用 render() 方法。接下来的三行语句创建了每个子视图。注意,所有子视图都使用相同的模型,这也是 Details 视图的模型。这就是模型/视图架构的强大之处;一个数据对象——在我们的例子中是一个跑步——可以以多种不同的方式呈现。当 render() 方法创建这些子视图时,它也会调用它们的 render() 方法,并将结果内容(即它们的 el 属性)附加到自己的 el 中。

步骤 3:实现属性视图

对于属性视图,我们希望展示所有与跑步相关的 Nike+ 属性。这些属性是由 Nike+ 服务返回的数据决定的;下面是一个示例:

{
    "activityId": "2126456911",
    "activityType": "RUN",
    "startTime": "2013-04-09T10:54:33Z",
    "activityTimeZone": "GMT-04:00",
    "status": "COMPLETE",
    "deviceType": "IPOD",
    "metricSummary": {
        "calories": 240,
        "fuel": 790,
        "distance": 3.7524,
        "steps": 0,
        "duration": "0:22:39.000"
    },
    "tags": [
        { "tagType": "WEATHER", "tagValue": "SUNNY"    },
        { "tagType": "NOTE"                            },
        { "tagType": "TERRAIN", "tagValue": "TRAIL"    },
        { "tagType": "SHOES", "tagValue": "Neo Trail"  },
        { "tagType": "EMOTION", "tagValue": "GREAT"    }
    ],
    "metrics": [
        { "intervalMetric": 10, "intervalUnit": "SEC",
          "metricType": "SPEED", "values": [*/* Data continues... */*] },
        { "intervalMetric": 10, "intervalUnit": "SEC",
          "metricType": "HEARTRATE", "values": [*/* Data continues... */*] },
        { "intervalMetric": 10, "intervalUnit": "SEC",
          "metricType": "DISTANCE", "values": [*/* Data continues... */*] },
    ],
    "gps": {
        "elevationLoss": 114.400024,
        "elevationGain": 109.00003,
        "elevationMax": 296.2,
        "elevationMin": 257,
        "intervalMetric": 10,
        "intervalUnit": "SEC",
        "waypoints": [*/* Data continues... */*]
    }
}

这些数据肯定可以通过一些清理工作变得更友好。为此,我们将利用之前添加到项目中的 Underscore.string 库。我们可以通过将该库“混合”进主 Underscore.js 库来确保该库的可用性。我们将在属性视图的 JavaScript 文件开头做这件事。

*/*Global Running, Backbone, JST, _*/*

_.mixin(_.str.exports());

Running.Views = Running.Views || {};

*// Code continues...*

注意,我们还将 Underscore.js 的全局变量(_)添加到了文件的初始注释中。

以 HTML 呈现这些信息的最直接方式是使用描述列表(<dl>)。每个属性可以作为列表中的一个项,其中描述项(<dt>)包含属性名称,描述数据(<dd>)则包含其值。为了实现这一点,我们将视图的 tagName 属性设置为 "dl",并创建一个通用的列表项模板。下面是我们属性视图代码的开始部分:

Running.Views.Properties = Backbone.View.extend({
    template: JST["app/scripts/templates/properties.ejs"],
    tagName: "dl",
    initialize: **function** () {
        **this**.listenTo(**this**.model, "change", **this**.render);
        **return** **this**;
    },
    render: **function** () {
        *// More code goes here*
        **return** **this**;
    }
});

下面是视图将使用的简单模板。

**<dt>**<%= key %>**</dt>**
**<dd>**<%= value %>**</dd>**

快速查看 Nike+ 数据发现,它包含嵌套对象。主对象的 metricSummary 属性本身就是一个对象。我们需要一个函数来遍历输入对象中的所有属性,同时构建 HTML 标记。递归函数在这里特别有效,因为它可以在遇到另一个嵌套对象时调用自己。接下来,我们为视图添加一个 obj2Html() 方法。该方法的核心将使用 Underscore.js 的 reduce() 函数,这对于当前任务非常适合。

obj2Html: **function**(obj) {
    **return** (
        _(obj).reduce(**function**(html, value, key) {

            *// Create the markup for the current*
            *// key/value pair and add it to the html variable*

            **return** html;
        }, "", **this**)
    );
}

在处理每个属性时,我们可以做的第一件事是改进键名。例如,我们想将startTime替换为Start Time。这时,Underscore.string 就派上了用场。它的humanize()函数将驼峰命名法转化为分开的单词,而titleize()函数则确保每个单词的首字母是大写的。我们将使用链式调用来在一个语句中执行这两个操作。

key = _.chain(key).humanize().titleize().value();

现在我们可以考虑值。如果它是一个数组,我们将把它替换为显示数组长度的字符串。

**if** (_(value).isArray()) {
    value = "[" + value.length + " items]";
}

接下来我们检查值是否是一个对象。如果是,我们将递归调用obj2Html()方法。

**if** (_(value).isObject()) {
    html += **this**.obj2Html(value);

对于其他类型,我们将把值转换为字符串,使用 Underscore.string 稍微格式化一下,并使用我们的模板。

} **else** {
    value = _(value.toString().toLowerCase()).titleize();
    html += **this**.template({ key: key, value: value });
}

我们可以对展示做一些其他的小改进,具体内容可以在书籍的源代码中找到。视图的最后一部分是实现render()方法。在这个方法中,我们使用toJSON()获取与 Run 模型对应的对象,然后开始用该对象进行obj2Html()递归处理。

render: **function** () {
    **this**.$el.html(**this**.obj2Html(**this**.model.toJSON()));
    **return** **this**;
}

结果是一个关于运行属性的完整图景,如图 9-5 所示。

完成的属性视图展示了与运行相关的所有数据。图 9-5. 完成的属性视图展示了与运行相关的所有数据。

步骤 4:实现地图视图

为了向用户展示他们运行的地图,我们依赖于第六章中的 Leaflet 库。使用该库需要对普通的 Backbone.js 视图实现做一些小的修改,但正如我们所看到的,这些修改对于其他视图也同样有用。Leaflet 会在页面中的一个容器元素内构建地图(通常是一个<div>),而这个容器元素必须有一个id属性,以便 Leaflet 能够找到它。如果我们在视图中包含一个id属性,Backbone.js 会负责添加这个id。这个操作很简单。

Running.Views.Map = Backbone.View.extend({
    id: "map",

在页面的标记中有<div id="map"></div>后,我们可以通过以下语句创建一个 Leaflet 地图:

**var** map = L.map(**this**.id);

我们可能会倾向于直接在视图的render()方法中做这件事,但这种方法有一个问题。往网页中添加(和删除)元素需要浏览器进行大量的计算。当 JavaScript 代码频繁执行这种操作时,页面的性能可能会显著下降。为了减少这个问题,Backbone.js 尽量减少添加(或删除)元素的次数,一种方法是一次性添加多个元素,而不是独立地添加每个元素。当它实现视图的render()方法时,就采用了这种方法。在将任何元素添加到页面之前,它会让视图完成整个标记的构建;只有完成后,它才会将这些标记添加到页面中。

这里的问题是,当render()第一次被调用时,页面中还没有<div id="map"></div>元素。如果我们调用 Leaflet,它将无法找到地图的容器,从而生成错误。我们需要做的是推迟render()中绘制地图的部分,直到 Backbone.js 将地图容器添加到页面之后。

幸运的是,Underscore.js 有一个名为defer()的工具函数,可以完成这一任务。我们将不再直接在render()方法中绘制地图,而是创建一个单独的方法。然后,在render()方法中,我们将推迟执行这个新方法。下面是实现这一点的代码:

render: **function** () {
    _.defer(_(**function**(){ **this**.drawMap(); }).bind(**this**));
},
drawMap: **function** () {
    **var** map = L.map(**this**.id);
    *// Code continues...*
}

如你所见,在我们的render()方法中,我们实际上使用了几个 Underscore.js 函数。除了defer(),我们还利用了bind()。后者确保在最终调用drawMap()时,this的值与视图内部的this值相同。

我们可以进一步改进此实现。有一个变化可以做:尽管render()第一次被调用时页面中没有<div id="map"></div>元素,但在后续调用render()时,该元素将会存在。在这些情况下,我们不需要推迟执行drawMap()。这就导致了以下我们render()方法的代码:

render: **function** () {
    **if** (document.getElementById(**this**.id)) {
        **this**.drawMap();
    } **else** {
        _.defer(_(**function**(){ **this**.drawMap(); }).bind(**this**));
    }
    **return** **this**;
},

既然我们已经在进行优化,不妨稍微修改一下initialize()方法。Yeoman 默认创建的方法是这样的:

initialize: **function** () {
    **this**.listenTo(**this**.model, "change", **this**.render);
},

然而,对于地图视图,我们实际上并不关心Run模型的任何属性变化。视图所需的唯一属性是gps,因此我们可以告诉 Backbone.js 仅在该特定属性发生变化时才通知我们。

initialize: **function** () {
    **this**.listenTo(**this**.model, "change:gps", **this**.render);
    **return** **this**;
},

你可能会想:“为什么Run模型的gps属性会发生变化呢?”我将在第十章讲解 Nike+ REST API 的细节时说明这一点。

解决了初步问题后,我们可以实现drawMap()函数,这实际上是一个非常简单的实现。步骤如下:

  1. 确保模型有一个gps属性,并且与其关联了航点。

  2. 如果旧的地图存在,则将其移除。

  3. 从航点数组中提取 GPS 坐标。

  4. 使用这些坐标创建路径。

  5. 创建一个包含该路径的地图,并在地图上绘制该路径。

  6. 添加地图瓦片。

生成的代码是这些步骤的直接实现。

drawMap: **function** () {
    **if** (**this**.model.get("gps") && **this**.model.get("gps").waypoints) {
        **if** (**this**.map) {
            **this**.map.remove();
        }
        **var** points = _(**this**.model.get("gps").waypoints).map(**function**(pt) {
            **return** [pt.latitude, pt.longitude];
        });
        **var** path = **new** L.Polyline(points, {color: "#1788cc"});
        **this**.map = L.map(**this**.id).fitBounds(path.getBounds())
            .addLayer(path);
        **var** tiles = L.tileLayer(
            "http://server.arcgisonline.com/ArcGIS/rest/services/Canvas/"+
            "World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}",
            {
                attribution: "Tiles &copy; Esri &mdash; "+
                             "Esri, DeLorme, NAVTEQ",
                maxZoom: 16
            }
        );
        **this**.map.addLayer(tiles);
    }
}

如你所见,在代码中,我们将 Leaflet 地图对象作为视图的一个属性进行存储。在视图内部,我们可以通过this.map访问该对象。

结果是一个显示跑步路线的漂亮地图,如图 9-6 所示。

跑步路线的地图视图图 9-6. 跑步路线的地图视图。

第 5 步:实现图表视图

我们需要实现的最后一个视图是图表视图,在这个视图中我们想要展示跑步过程中的步频、心率和海拔。这个视图是最复杂的,但几乎所有的代码都与数据追踪值中的示例相同,因此这里不需要重复。

你可以在图 9-7 中查看交互式结果。

另一种视图展示跑步的图表。图 9-7. 另一种视图展示跑步的图表。

本书的源代码包括完整的实现。如果你在详细查看该实现时,有几点需要注意:

  • 就像 Leaflet 和地图容器一样,Flot 期望在网页中存在一个图表容器。我们可以使用相同的 defer 技巧来防止 Flot 错误。

  • Nike+ 至少返回四种类型的图表作为指标:距离、心率、速度和 GPS 信号强度。我们实际上只关心前两个。一开始,似乎从速度计算步频最简单,但速度并非所有活动都有。然而,距离是有的,我们可以从距离和时间推导出步频。

  • 如果有 GPS 路径点数据,我们还可以绘制海拔图表,但这些数据存在于模型的一个单独属性中(而不是 metrics 属性)。

  • 截至目前,Nike 的 GPS 数据响应存在一个小问题。它声称测量数据与其他指标(每 10 秒一次)在相同的时间尺度上,但实际上 GPS 测量数据是在不同的间隔上报告的。为了绕过这个问题,我们忽略报告的间隔并自行计算一个间隔。同时,我们希望将海拔图表标准化为与其他图表相同的时间尺度。这样做的额外好处是可以对 GPS 海拔数据进行平均;在这里平均值很有用,因为 GPS 海拔测量通常不太准确。

总结

在本章中,我们开始构建一个基于数据和数据可视化的完整 Web 应用程序。为了帮助组织和协调我们的应用程序,我们基于 Backbone.js 库,并依赖 Yeoman 工具来创建应用程序的框架和模板代码。Backbone.js 使我们能够将应用程序分离为模型和视图,以便管理数据的代码不需要担心数据如何呈现(反之亦然)。

在下一章,我们将使我们的应用程序能够与 Nike+ 接口进行通信,并添加一些细节来改善用户与页面的交互。

第十章:构建数据驱动的 Web 应用程序:第二部分

在第九章中,我们搭建了 web 应用程序的框架,并展示了每个视图将要显示的可视化内容。但是在我们的 web 应用程序完成之前,我们还有一些其他的细节需要处理。首先,我们必须使 web 应用程序与 Nike+ 服务进行通信,并处理该服务特有的一些问题。接下来,我们将着手让我们的应用程序更加易于导航。在本章中,我们将讨论以下内容:

  • 如何将应用程序模型与外部 REST API 连接

  • 如何在单页应用程序中支持 Web 浏览器约定

连接到 Nike+ 服务

尽管我们的示例应用程序依赖于 Nike+ 服务来获取数据,但我们并没有查看该服务接口的具体细节。正如我提到的,Nike+ 并没有完全遵循像 Backbone.js 这样的应用库所期望的常见 REST API 约定。但是在这方面,Nike+ 并不算非常特殊。实际上,REST API 并没有一个真正的 标准,许多其他服务也采取了类似于 Nike+ 的方法。幸运的是,Backbone.js 已经预见到了这种变化。正如我们将在接下来的步骤中看到的,扩展 Backbone.js 以支持 REST API 的变化并不像想象中那样困难。

步骤 1:授权用户

正如你可能预料到的,Nike+ 并不允许互联网的任何人获取任何用户的跑步详情。用户期望至少对这些信息保持一定的隐私。因此,在我们的应用程序能够获取任何跑步信息之前,它需要用户的许可。我们在这里不详细讨论这个过程,但其结果将是一个 authorization_token。这个对象是一个任意字符串,我们的应用程序必须将其包含在每个 Nike+ 请求中。如果令牌缺失或无效,Nike+ 将拒绝我们的应用程序访问数据。

到目前为止,我们一直让 Backbone.js 处理 REST API 的所有细节。接下来,我们需要修改 Backbone.js 构造 AJAX 请求的方式。幸运的是,这并不像听起来那么复杂。我们只需要在我们的 Runs 集合中添加一个 sync() 方法。当集合中存在 sync() 方法时,每当 Backbone.js 发起 AJAX 请求时,都会调用它。(如果集合中没有这个方法,Backbone.js 会调用其主要的 Backbone.sync() 方法。)我们将在集合中直接定义这个新方法。

Running.Collections.Runs = Backbone.Collection.extend({

    sync: **function**(method, collection, options) {
        *// Handle the AJAX request*
    }

如你所见,sync() 被传递了一个 method(如 GETPOST 等)、相关的集合以及一个包含请求选项的对象。为了将授权令牌发送到 Nike+,我们可以通过这个 options 对象将其作为参数添加进去。

sync: **function**(method, collection, options) {
    options = options || {};
    _(options).extend({
        data: { authorization_token: **this**.settings.authorization_token }
    });
    Backbone.sync(method, collection, options);
}

方法中的第一行确保 options 参数存在。如果调用者没有提供值,我们将其设置为空对象({})。接下来的语句使用来自 Underscore.js 的 extend() 工具方法,向 options 对象添加一个 data 属性。data 属性本身是一个对象,我们在其中存储授权令牌。接下来我们将看看如何做到这一点,但首先让我们完成 sync() 方法。一旦添加了令牌,我们的请求就是一个标准的 AJAX 请求,所以我们可以让 Backbone.js 通过调用 Backbone.sync() 来继续处理。

现在我们可以将注意力转向从 sync() 方法中获取授权令牌的 settings 对象。我们使用这个对象来保存与集合相关的属性,类似于模型的属性。Backbone.js 不会自动为我们创建这个对象,但我们可以轻松地自己创建。我们将在集合的 initialize() 方法中创建它。该方法接受两个参数:一个是集合的模型数组,另一个是任何集合选项。

Running.Collections.Runs = Backbone.Collection.extend({

    initialize: **function**(models, options) {
        **this**.settings = { authorization_token: "" };
        options = options || {};
        _(**this**.settings).extend(_(options)
            .pick(_(**this**.settings).keys()));
    },

initialize() 方法中的第一条语句定义了一个 settings 对象用于集合,并为该对象设置了默认值。由于没有合适的默认值用于授权令牌,我们将使用一个空字符串。

接下来的语句确保 options 对象存在。如果没有作为参数传递,我们至少会有一个空对象。

最后一条语句提取 settings 中的所有键,查找 options 对象中具有相同键的任何值,并通过使用新的键值扩展 settings 对象。再次利用了 Underscore.js 的一些工具:extend()pick()

当我们首次创建 Runs 集合时,可以将授权令牌作为参数传入。我们将一个空数组作为第一个参数,因为目前我们没有任何模型用于集合。这些模型将来自 Nike+。在以下的代码片段中,我们使用一个虚拟值作为授权令牌。真实的应用程序会使用 Nike 提供的代码来获取真实的值。

**var** runs = **new** Running.Collections.Runs([], {
    authorization_token: "authorize me"
});

只需少量额外的代码,我们就将授权令牌添加到了向 Nike+ 发送的 AJAX 请求中。

第 2 步:接受 Nike+ 响应

当我们的集合查询 Nike+ 获取用户活动列表时,Backbone.js 已准备好接收特定格式的响应。更具体地说,Backbone.js 期望响应是一个简单的模型数组。

[
  { "activityId": "2126456911", */* Data continues... */* },
  { "activityId": "2125290225", */* Data continues... */* },
  { "activityId": "2124784253", */* Data continues... */* },
  *// Data set continues...*
]

然而,实际上,Nike+ 将其响应返回为一个对象。活动数组是该对象的一个属性。

{
  "data": [
    { "activityId": "2126456911", */* Data continues... */* },
    { "activityId": "2125290225", */* Data continues... */* },
    { "activityId": "2124784253", */* Data continues... */* },
    *// Data set continues...*
  ],
  *// Response continues...*
}

为了帮助 Backbone.js 处理这个响应,我们在集合中添加了一个 parse() 方法。这个方法的工作是接收服务器提供的响应,并返回 Backbone.js 所期望的响应格式。

Running.Collections.Runs = Backbone.Collection.extend({

    parse: **function**(response) {
        **return** response.data;
    },

在我们的案例中,我们只是返回响应中的 data 属性。

第 3 步:分页集合

接下来我们要处理的 Nike+ API 的一个方面是分页。当我们请求某个用户的活动时,服务通常不会返回所有的活动。用户可能在 Nike+ 中存储了成千上万的活动,一次性返回所有活动可能会使应用程序不堪重负。这样做肯定会带来明显的延迟,因为应用程序必须等待整个响应返回才能进行处理。为了避免这个问题,Nike+ 将用户活动分为多个页面,并一次返回一页活动。我们需要调整应用程序以适应这种行为,但我们将获得更加流畅的用户体验。

我们要进行的第一个调整是在请求中。我们可以向请求中添加参数,表示我们准备接受多少个活动响应。这两个参数是 offsetcountoffset 告诉 Nike+ 在响应中返回哪个活动作为第一个,而 count 则表示 Nike+ 应该返回多少个活动。例如,如果我们想要获取前 20 个活动,可以将 offset 设置为 1,将 count 设置为 20。然后,要获取接下来的 20 个活动,我们将 offset 设置为 21(并保持 count20)。

我们像添加授权令牌一样,向请求中添加这些参数——在 sync() 方法中。

sync: **function**(method, collection, options) {
    options = options || {};
    _(options).extend({
        data: {
            authorization_token: **this**.settings.authorization_token,
            count: **this**.settings.count,
            offset: **this**.settings.offset
        }
    });
    Backbone.sync(method, collection, options);
}

我们还需要在初始化期间为这些设置提供默认值。

initialize: **function**(models, options) {
    **this**.settings = {
        authorization_token: "",
        count: 25,
        offset: 1
    };

这些值将返回前 25 个活动,但这只是一个开始。我们的用户可能希望查看所有的跑步记录,而不仅仅是前 25 个活动。为了获取更多的活动,我们需要向服务器发出更多的请求。一旦我们获得了前 25 个活动,就可以请求接下来的 25 个活动。当这些活动返回后,我们可以再请求 25 个。我们将继续这样做,直到达到某个合理的限制,或者服务器没有更多的活动可供返回。

首先,我们将合理的限制定义为另一个设置值。在下面的代码中,我们将使用 10000 作为这个限制。

initialize: **function**(models, options) {
    **this**.settings = {
        authorization_token: "",
        count: 25,
        offset: 1,
        max: 10000
    };

接下来,我们需要修改集合的 fetch() 方法,因为标准的 Backbone.js fetch() 无法处理分页。在我们实现此方法时,有三个步骤:

  1. 保存 Backbone.js 在请求中使用的所有选项的副本。

  2. 通过添加一个回调函数来扩展这些选项,当请求成功时调用这个回调。

  3. 将控制权交给正常的 Backbone.js fetch() 方法处理集合。

这些步骤在以下实现中对应一行代码。最后一个步骤看起来可能有点复杂,但如果一步一步地理解,实际上是很有道理的。表达式 Backbone.Collection.prototype.fetch 指的是 Backbone.js 集合的正常 fetch() 方法。我们使用 .call() 来执行这个方法,这样我们可以为方法设置上下文,使其成为我们的集合。这是 call() 的第一个 this 参数。第二个参数包含 fetch() 的选项,这些选项就是我们在第 2 步中创建的扩展选项。

Running.Collections.Runs = Backbone.Collection.extend({

    fetch: **function**(options) {
        **this**.fetchoptions = options = options || {};
        _(**this**.fetchoptions).extend({ success: **this**.fetchMore });
        **return** Backbone.Collection.prototype.fetch.call(
            **this**, **this**.fetchoptions
        );
    },

通过为 AJAX 请求添加success回调,我们请求在请求完成时收到通知。实际上,我们已经指定希望调用this.fetchMore()函数。现在是时候编写这个函数了;它也是 Runs 集合的方法。这个函数会检查是否还有更多活动。如果有,它会像前面的代码一样,执行另一次对 Backbone.js 常规集合fetch()的调用。

fetchMore: **function**() {
    **if** (**this**.settings.offset < **this**.settings.max) {
        Backbone.Collection.prototype.fetch.call(**this**, **this**.fetchoptions);
    }
}

由于fetchMore()是通过查看设置来决定何时停止的,我们需要更新这些值。因为我们已经有了一个parse()方法,并且 Backbone 会在每次响应时调用这个方法,所以更新操作放在这里是很方便的。我们在return语句之前添加一些代码。如果服务器返回的活动数量少于我们请求的数量,那么我们已经用尽了活动列表。我们将offset设置为max,以便fetchMore()知道停止。否则,我们会将offset增加活动的数量。

parse: **function**(response) {
    **if** (response.data.length < **this**.settings.count) {
        **this**.settings.offset = **this**.settings.max;
    } **else** {
        **this**.settings.offset += **this**.settings.count;
    }
    **return** response.data;
}

到目前为止,我们编写的代码几乎完成,但它有一个问题。当 Backbone.js 获取一个集合时,它假设是在获取整个集合。因此,默认情况下,每次获取的响应都会将集合中已经存在的模型替换为响应中的模型。这种行为在第一次调用fetch()时没问题,但对于fetchMore()来说就不合适了,因为fetchMore()是为了将数据添加到集合中,而不是替换它。幸运的是,我们可以通过设置remove选项轻松地调整这种行为。

在我们的fetch()方法中,我们将这个选项设置为true,这样 Backbone.js 就会开始一个新的集合。

fetch: **function**(options) {
    **this**.fetchoptions = options = options || {};
    _(**this**.fetchoptions).extend({
        success: **this**.fetchMore,
        remove: **true**
     });
    **return** Backbone.Collection.prototype.fetch.call(**this**,
        **this**.fetchoptions
    );
}

现在,在fetchMore()方法中,我们可以将这个选项重置为false,这样 Backbone.js 就会将模型添加到集合中,而不是替换它们。

fetchMore: **function**() {
    **this**.fetchoptions.remove = **false**;
    **if** (**this**.settings.offset < **this**.settings.max) {
        Backbone.Collection.prototype.fetch.call(**this**, **this**.fetchoptions);
    }
}

fetchMore()方法仍然存在一个小问题。该代码引用了集合的属性(this.fetchoptionsthis.settings),但是该方法将在 AJAX 请求完成时异步调用。当那时发生时,集合将不在上下文中,所以this不会指向集合。为了解决这个问题,我们可以在初始化时将fetchMore()绑定到集合。再次地,Underscore.js 的一个实用函数派上了用场。

initialize: **function**(models, options) {
    _.bindAll(**this**, "fetchMore");

在这一步的最后部分,我们可以使我们的集合对使用它的代码更友好。为了继续获取额外的页面,我们已经为fetch()的选项设置了success回调。那么,如果使用我们集合的代码有自己的回调怎么办呢?不幸的是,我们已经删除了那个回调,替换成了我们自己的回调。更好的做法是简单地暂时保留一个现有的回调函数,然后在我们完成获取整个集合之后恢复它。我们将在fetch()方法中首先执行这一操作。以下是该方法的完整代码:

fetch: **function**(options) {
    **this**.fetchoptions = options = options || {};
    **this**.fetchsuccess = options.success;
    _(**this**.fetchoptions).extend({
        success: **this**.fetchMore,
        remove: **true**
        });
    **return** Backbone.Collection.prototype.fetch.call(**this**,
        **this**.fetchoptions
    );
}

这是fetchMore()的代码:

fetchMore: **function**() {
    **this**.fetchoptions.remove = **false**;
    **if** (**this**.settings.offset < **this**.settings.max) {
        Backbone.Collection.prototype.fetch.call(**this**, **this**.fetchoptions);
    } **else** **if** (**this**.fetchsuccess) {
        **this**.fetchsuccess();
    }
}

现在,当我们用尽服务器返回的列表时,可以在fetchMore()中执行回调。

第 4 步:动态更新视图

通过分批获取运行记录集合,我们使得应用程序变得更加响应迅速。即使在等待从服务器检索其余用户运行记录时,我们也可以开始显示前 25 条运行记录的汇总数据。然而,要有效地做到这一点,我们需要对我们的汇总视图进行一些小改动。目前,视图正在监听集合的任何变化。当发生变化时,视图会从头开始重新渲染。

initialize: **function** () {
    **this**.listenTo(**this**.collection, "change", **this**.render);
    **return** **this**;
}

每次我们获取新的运行记录页面时,集合会发生变化,代码会重新渲染整个视图。这几乎肯定会让我们的用户感到烦扰,因为每次获取页面时,浏览器会暂时清空页面然后再重新填充内容。相反,我们希望只渲染新添加模型的视图,保持现有模型视图不变。为了做到这一点,我们可以监听 "add" 事件而不是 "change" 事件。当该事件触发时,我们只需要渲染该模型的视图。我们已经实现了为单个 Run 模型创建并渲染视图的代码:renderRun() 方法。因此,我们可以按照如下方式修改我们的汇总视图:

initialize: **function** () {
    **this**.listenTo(**this**.collection, "add", **this**.renderRun);
    **return** **this**;
}

现在,当我们的集合从服务器获取新的运行模型时,它们会被添加到集合中,触发一个 "add" 事件,视图会捕获该事件。然后,视图会在页面上渲染每一条运行记录。

步骤 5:过滤集合

尽管我们的应用程序只关注跑步,Nike+ 服务支持多种体育活动。当我们的集合从服务端获取数据时,响应将包含这些其他活动。为了避免将它们包含在我们的应用中,我们可以将它们从响应中过滤掉。

我们可以手动过滤响应,检查每项活动并移除非跑步的记录。然而,这样做需要大量的工作,而 Backbone.js 提供了一个更简便的方法。为了利用 Backbone.js,我们将首先向我们的 Run 模型添加一个 validate() 方法。这个方法接收潜在模型的属性和创建或修改时使用的任何选项作为参数。在我们的案例中,我们只关心属性。我们会检查确保 activityType 等于 "RUN"

Running.Models.Run = Backbone.Model.extend({
    validate: **function**(attributes, options) {
        **if** (attributes.activityType.toUpperCase() !== "RUN") {
            **return** "Not a run";
        }
    },

从这段代码中,你可以看到 validate() 函数的行为。如果模型中有错误,validate() 会返回一个值。只要 JavaScript 将其视为 true,返回值的具体内容并不重要。如果没有错误,validate() 就不需要返回任何值。

现在我们的模型已经有了 validate() 方法,我们需要确保 Backbone.js 会调用它。Backbone.js 会在模型被创建或修改时自动检查 validate(),但通常不会验证来自服务器的响应。然而,在我们的情况下,我们确实需要验证来自服务器的响应。这就要求我们在 Runs 集合的 fetch() 选项中设置 validate() 属性。以下是包括此更改的完整 fetch() 方法。

Running.Collections.Runs = Backbone.Collection.extend({
    fetch: **function**(options) {
        **this**.fetchoptions = options = options || {};
        **this**.fetchsuccess = options.success;
        _(**this**.fetchoptions).extend({
            success: **this**.fetchMore,
            remove: **true**,
            validate: **true**
          });
        **return** Backbone.Collection.prototype.fetch.call(**this**,
          **this**.fetchoptions
        );
    },

现在,当 Backbone.js 接收到服务器响应时,它会将响应中的所有模型都传递给模型的validate()方法。任何未通过验证的模型都会从集合中移除,我们的应用也不必处理那些不是跑步记录的活动。

第 6 步:解析响应

既然我们正在为 Run 模型添加代码,那么还有另一个改动会让 Backbone.js 感到高兴。Backbone.js 要求模型具有一个属性,使每个对象具有唯一性;它可以利用这个标识符来区分不同的跑步记录。默认情况下,Backbone.js 期望该属性为id,因为这是一个常见的约定。然而,Nike+的跑步记录没有id属性。相反,该服务使用activityId属性。我们可以通过在模型中添加一个额外的属性,告诉 Backbone.js 这一点。

Running.Models.Run = Backbone.Model.extend({
    idAttribute: "activityId",

这个属性让 Backbone.js 知道,对于我们的跑步记录,activityId属性是唯一的标识符。

第 7 步:检索详情

到目前为止,我们一直依赖集合的fetch()方法来获取运行数据。该方法从服务器获取一组跑步记录。然而,当 Nike+返回活动列表时,它并不包含每个活动的完整细节。它返回的是摘要信息,但省略了详细的度量数组和任何 GPS 数据。获取这些详细信息需要额外的请求,因此我们需要对我们的 Backbone.js 应用做出一个小的调整。

我们将首先请求作为 Charts 视图基础的详细度量数据。当 Runs 集合从服务器获取跑步记录列表时,每个 Run 模型最初会有一个空的metrics数组。为了获取该数组的详细信息,我们必须再次向服务器发送请求,并在请求的 URL 中包含活动标识符。例如,如果获取跑步记录列表的 URL 是api.nike.com/v1/me/sport/activities/,那么获取特定跑步记录的详细信息,包括其度量数据的 URL 是api.nike.com/v1/me/sport/activities/2126456911/。这个 URL 末尾的数字2126456911就是该跑步记录的activityId

多亏了我们在本节之前所做的步骤,在 Backbone.js 中获取这些细节变得非常容易。我们所要做的就是fetch()模型。

run.fetch();

Backbone.js 知道 URL 的根路径,因为我们在 Runs 集合中设置了它(而且我们的模型是该集合的一部分)。Backbone.js 也知道每个跑步记录的唯一标识符是activityId,因为我们在前一步中设置了该属性。而且,幸运的是,Backbone.js 足够聪明,能够将这些信息结合起来并发起请求。

然而,我们在某些方面必须帮助 Backbone.js。Nike+应用对所有请求都需要一个授权令牌,到目前为止,我们只在集合中为该令牌添加了代码。我们还需要将相同的代码添加到模型中。此代码几乎与本节第一步中的代码相同:

   Running.Models.Run = Backbone.Model.extend({
       sync: **function**(method, model, options) {
           options = options || {};
           _(options).extend({
               data: {
                   authorization_token:
➊                     **this**.collection.settings.authorization_token
               }
           });
           Backbone.sync(method, model, options);
       },

我们首先确保options对象存在,然后通过添加授权令牌来扩展它。最后,我们调用常规的 Backbone.js sync()方法。在 ➊ 处,我们直接从集合中获取令牌的值。我们可以在这里使用this.collection,因为 Backbone.js 会将模型的collection属性设置为引用它所属的集合。

现在我们必须决定何时以及在哪里调用模型的fetch()方法。实际上,我们并不需要在应用主页面上的 Summary 视图中获取度量详情;只有在创建 Details 视图时,我们才需要去获取这些数据。我们可以方便地在视图的initialize()方法中执行此操作。

Running.Views.Details = Backbone.View.extend({
    initialize: **function** () {
        **if** (!**this**.model.get("metrics") ||
            **this**.model.get("metrics").length === 0) {
            **this**.model.fetch();
        }
    },

你可能认为请求的异步特性可能会给我们的视图带来问题。毕竟,我们在渲染新创建的视图时试图绘制图表。是不是会在服务器响应之前(即在我们还没有任何图表数据之前)就绘制了图表?实际上,几乎可以肯定的是,我们的视图会在数据可用之前就尝试绘制图表。尽管如此,由于我们构建视图的方式,根本没有问题。

魔法就在我们 Charts 视图的initialize()方法中的一行语句。

Running.Views.Charts = Backbone.View.extend({
    initialize: **function** () {
        **this**.listenTo(**this**.model,
            "change:metrics change:gps", **this**.render);
        *// Code continues...*

该语句告诉 Backbone.js,当关联模型的metrics(或gps)属性发生变化时,我们的视图希望知道。服务器响应fetch()并更新该属性后,Backbone.js 会调用视图的render()方法,并尝试(再次)绘制图表。

这个过程中涉及了很多内容,所以一步步来看可能会更有帮助。

  1. 应用程序调用了 Runs 集合的fetch()方法。

  2. Backbone.js 向服务器发送请求,获取活动列表。

  3. 服务器的响应包括每个活动的摘要信息,Backbone.js 使用这些信息来创建初始的 Run 模型。

  4. 应用程序为特定的 Run 模型创建一个 Details 视图。

  5. 该视图的initialize()方法调用了特定模型的fetch()方法。

  6. Backbone.js 向服务器发送请求,获取该活动的详细信息。

  7. 与此同时,应用程序渲染它刚刚创建的 Details 视图。

  8. Details 视图创建了一个 Charts 视图并渲染了该视图。

  9. 因为没有图表的数据,Charts 视图实际上并没有向页面添加任何内容,而是在等待接收任何与模型相关的变化。

  10. 最终,服务器在第 6 步中对请求做出响应,并提供了活动的详细信息。

  11. Backbone.js 使用新的详细信息更新模型,并注意到metrics属性因此发生了变化。

  12. Backbone.js 触发了 Charts 视图一直在监听的变更事件。

  13. Charts 视图接收到事件触发器,并再次渲染自己。

  14. 由于图表数据现在已经可用,render()方法能够创建图表并将它们添加到页面上。

呼!幸好 Backbone.js 处理了所有这些复杂性。

到此为止,我们已经成功地检索到跑步的详细指标,但还没有添加任何 GPS 数据。Nike+ 需要一个额外的请求来获取这些数据,所以我们将使用类似的过程。然而,在这种情况下,我们不能依赖 Backbone.js,因为 GPS 请求的 URL 是 Nike+ 独有的。该 URL 是通过将单个活动的 URL 和 /gps 拼接在一起形成的——例如,* api.nike.com/v1/me/sport/activities/2126456911/gps/*。

为了发出额外的请求,我们可以在常规的 fetch() 方法中添加一些代码。我们将在 Backbone.js 请求指标详细信息的同时请求 GPS 数据。基本方法如以下代码片段所示,非常简单。我们首先检查活动是否有任何 GPS 数据。我们可以通过检查服务器提供的活动摘要中的 isGpsActivity 属性来做到这一点。如果有,我们就可以请求该数据。无论如何,我们还需要执行模型的常规 fetch() 过程。我们通过获取模型的标准 fetch() 方法的引用(Backbone.Model.prototype.fetch),然后调用该方法来实现。我们将相同的 options 参数传递给它。

Running.Models.Run = Backbone.Model.extend({
    fetch: **function**(options) {
        **if** (**this**.get("isGpsActivity")) {
            *// Request GPS details from the server*
        }
        **return** Backbone.Model.prototype.fetch.call(**this**, options);
     },

接下来,为了向 Nike+ 发出请求,我们可以使用 jQuery 的 AJAX 函数。由于我们请求的是 JavaScript 对象(JSON 数据),因此 $.getJSON() 函数是最合适的。首先,我们通过将 this 赋值给局部变量 model 来保留对跑步的引用。我们需要这个变量,因为在 jQuery 执行我们的回调时,this 将不再指向模型。然后,我们调用 $.getJSON() 并传递三个参数。第一个参数是请求的 URL。我们通过调用模型的 url() 方法并附加尾部的 /gps 来从 Backbone.js 获取这个 URL。第二个参数是请求时要包含的数据值。像往常一样,我们需要包含一个授权令牌。就像之前一样,我们可以从集合中获取该令牌的值。最后一个参数是回调函数,当 jQuery 收到服务器响应时执行。在我们的例子中,函数会将模型的 gps 属性设置为响应数据。

**if** (**this**.get("isGpsActivity")) {
    **var** model = **this**;
    $.getJSON(
        **this**.url() + "/gps",
        { authorization_token:
          **this**.collection.settings.authorization_token },
        **function**(data) { model.set("gps", data); }
    );
}

不出所料,检索 GPS 数据的过程与检索详细指标的过程相同。最初,我们的地图视图没有创建跑步地图所需的数据。然而,由于它在监听模型的 gps 属性的变化,因此当数据可用时,它会收到通知。此时,它可以完成 render 函数,用户将能够查看跑步的漂亮地图。

将一切整合起来

在本章的这一部分,我们已经具备了构建一个简单数据驱动型 Web 应用的所有组件。现在,我们将这些组件组装成应用程序。在这一节结束时,我们将拥有一个完整的应用程序。用户通过访问网页启动应用程序,而我们的 JavaScript 代码则从这里开始运行。结果是一个单页应用(SPA)。SPA 之所以流行,是因为 JavaScript 代码能够立即响应用户的操作,这比传统网站通过服务器与位于遥远网络另一端的服务器通信要快得多。用户通常会对这种迅速且响应灵敏的结果感到满意。

尽管我们的应用程序只在一个网页上执行,但用户仍然期望浏览器能提供某些行为。比如,他们希望能够书签保存页面、与朋友分享,或使用浏览器的前进和后退按钮进行导航。传统的网站可以依赖浏览器来支持这些行为,但单页应用却不能。如我们接下来的步骤所示,我们必须编写一些额外的代码,以实现用户期望的行为。

步骤 1:创建 Backbone.js 路由器

到目前为止,我们已经看过了三个 Backbone.js 组件——模型、集合和视图——这些都可以在任何 JavaScript 应用程序中派上用场。第四个组件是路由器,它对于单页应用特别有用。你不会惊讶地发现,我们可以使用 Yeoman 来创建路由器的脚手架。

$ **yo** backbone:router app
   **create** app/scripts/routes/app.js
   **invoke**   backbone-mocha:router
   **create**     test/routers/app.spec.js

请注意,我们将路由器命名为app。正如你从这个名字中可能会预期的那样,我们将这个路由器作为应用程序的主要控制器。这个方法有优缺点。一些开发者认为路由器应该严格限于路由功能,而另一些开发者则认为路由器是协调整个应用程序的自然位置。对于像我们这样简单的示例,将一些额外的代码添加到路由器中来控制应用程序其实并不会带来太大问题。然而,在复杂的应用程序中,最好还是将路由功能与应用程序控制分开。Backbone.js 的一个优点就是它能够支持这两种方法。

在脚手架搭建完成后,我们可以开始将路由器代码添加到app.js文件中。我们将定义的第一个属性是routes。这个属性是一个对象,其键是 URL 片段,值是路由器的方法。下面是我们的起点。

Running.Routers.App = Backbone.Router.extend({
    routes: {
        "":         "summary",
        "runs/:id": "details"
    },
});

第一个路由的 URL 片段为空("")。当用户访问我们的页面且没有指定路径时,路由器将调用其summary()方法。例如,如果我们使用greatrunningapp.com域名托管我们的应用程序,那么用户在浏览器中输入greatrunningapp.com/时就会触发这个路由。在我们查看第二个路由之前,让我们看看summary()方法的作用。

代码和我们之前看到的一样。summary() 方法创建一个新的 Runs 集合,获取该集合,创建该集合的 Summary 视图,并将该视图渲染到页面上。访问我们应用程序首页的用户将看到他们的运行摘要。

summary: **function**() {
    **this**.runs = **new** Running.Collections.Runs([],
        {authorizationToken: "authorize me"});
    **this**.runs.fetch();
    **this**.summaryView = **new** Running.Views.Summary({collection: **this**.runs});
    $("body").html(**this**.summaryView.render().el);
},

现在我们可以考虑第二条路由。它的 URL 片段是 runs/:id。其中 runs/ 是标准的 URL 路径,而 :id 是 Backbone.js 用来标识任意变量的方式。通过这条路由,我们告诉 Backbone.js 寻找一个以 greatrunningapp.com/runs/ 开头的 URL,并将其后面的部分视为 id 参数的值。我们将在路由器的 details() 方法中使用这个参数。下面是我们开始开发该方法的方式:

details: **function**(id) {
    **this**.run = **new** Running.Models.Run();
    **this**.run.id = id;
    **this**.run.fetch();
    **this**.detailsView = **new** Running.Views.Details({model: **this**.run});
    $("body").html(**this**.detailsView.render().el);
    },

如你所见,代码几乎与 summary() 方法相同,只是我们这里只显示了单个运行,而不是整个集合。我们创建了一个新的 Run 模型,将其 id 设置为 URL 中的值,从服务器获取该模型,创建一个 Details 视图,并将该视图渲染到页面上。

路由器让用户可以直接访问单个运行,通过使用合适的 URL。例如,URL greatrunningapp.com/runs/2126456911 将获取并显示 activityId 等于 2126456911 的运行的详细信息。注意,路由器无需关心是什么特定的属性定义了模型的唯一标识符。它使用通用的 id 属性。只有模型本身需要知道服务器使用的实际属性名称。

有了路由器,我们的单页面应用程序可以支持多个 URL。一个 URL 显示所有运行的摘要,而其他 URL 显示特定运行的详细信息。由于 URL 是独立的,用户可以像浏览不同的网页一样处理它们。他们可以将其添加到书签,发送电子邮件,或者在社交网络上分享。每当他们或他们的朋友返回某个 URL 时,它将显示与之前相同的内容。这正是用户对 Web 的期望行为。

然而,用户还期望有另一种行为,我们尚未支持。用户希望能使用浏览器的后退和前进按钮在浏览历史中进行导航。幸运的是,Backbone.js 提供了一个工具来处理这个功能。它就是 history 功能,我们可以在应用路由器初始化时启用它。

Running.Routers.App = Backbone.Router.extend({
    initialize: **function**() {
        Backbone.history.start({pushState: **true**});
    },

对于我们简单的应用程序,这就是我们处理浏览历史的全部工作。其余的由 Backbone.js 负责。

注意

支持多个 URL 可能需要对您的 Web 服务器进行一些配置。更具体地说,您需要让服务器将所有 URL 映射到同一个 index.html 文件。此配置的细节取决于 Web 服务器技术。对于开源的 Apache 服务器, .htaccess 文件可以定义这种映射。

步骤 2:支持不属于任何集合的 Run 模型

不幸的是,如果我们尝试在现有的 Run 模型中使用上述代码,我们将遇到一些问题。首先是我们的 Run 模型依赖于它的父集合。例如,它使用this.collection.settings.authorization_token来找到授权令牌。然而,当浏览器直接访问某个特定 run 的 URL 时,不会有集合。以下代码对这个问题做了调整:

    Running.Routers.App = Backbone.Router.extend({
       routes: {
           "":         "summary",
           "runs/:id": "details"
       },
       initialize: **function**(options) {
           **this**.options = options;
           Backbone.history.start({pushState: **true**});
       },
       summary: **function**() {
           **this**.runs = **new** Running.Collections.Runs([],
➊             {authorizationToken: **this**.options.token});
           **this**.runs.fetch();
           **this**.summaryView = **new** Running.Views.Summary({
               collection: **this**.runs});
           $("body").html(**this**.summaryView.render().el);
       },
       details: **function**(id) {
           **this**.run = **new** Running.Models.Run({},
➋             {authorizationToken: **this**.options.token});
           **this**.run.id = id;
           **this**.run.fetch();
           **this**.detailsView = **new** Running.Views.Details({
               model: **this**.run});
           $("body").html(**this**.detailsView.render().el);
   });

现在我们在创建 Run 模型时提供授权令牌 ➋。我们还将其值作为选项传递给在创建时的集合 ➊。

接下来,我们需要修改 Run 模型,以使用这个新参数。我们将像在 Runs 集合中一样处理令牌。

Running.Models.Run = Backbone.Model.extend({
    initialize: **function**(attrs, options) {
        **this**.settings = { authorization_token: "" };
        options = options || {};
        **if** (**this**.collection) {
            _(**this**.settings).extend(_(**this**.collection.settings)
                .pick(_(**this**.settings).keys()));
        }
        _(**this**.settings).extend(_(options)
            .pick(_(**this**.settings).keys()));
},

我们首先为所有设置定义默认值。与集合不同,我们的模型所需的唯一设置是authorization_token。接下来,我们确保我们有一个options对象。如果没有提供,我们就创建一个空对象。在第三步,我们通过查看this.collection来检查模型是否属于一个集合。如果该属性存在,我们就从集合中获取任何设置并覆盖默认值。最后一步将用任何作为选项传递给构造函数的设置来覆盖结果。当像前面的代码一样,我们的路由提供了一个authorization_token值时,模型将使用这个值。当模型是集合的一部分时,模型没有与之关联的特定令牌。在这种情况下,我们会回退到集合的令牌。

现在我们有了授权令牌,可以将其添加到模型的 AJAX 请求中。代码与我们在 Runs 集合中的代码几乎相同。我们需要一个属性来指定 REST 服务的 URL,并且我们需要覆盖常规的sync()方法,将令牌添加到所有请求中。

urlRoot: "https://api.nike.com/v1/me/sport/activities",

sync: **function**(method, model, options) {
    options = options || {};
    _(options).extend({
        data: { authorization_token: **this**.settings.authorization_token }
    });
    Backbone.sync(method, model, options);
},

这段额外的代码处理了授权,但我们的模型仍然存在问题。在前一部分中,Run 模型仅作为 Runs 集合的一部分存在,获取该集合的操作会为每个模型填充摘要属性,例如isGpsActivity。每当我们尝试获取模型的详细信息时,模型可以安全地检查该属性,并在适当时同时发起 GPS 数据请求。然而,现在我们单独创建一个 Run 模型,没有集合的帮助。当我们获取模型时,我们唯一知道的属性是唯一标识符。因此,在服务器响应获取请求之前,我们无法决定是否请求 GPS 数据。

为了将 GPS 数据请求与一般的获取请求分开,我们可以将该请求移到一个独立的方法中。代码与之前相同(当然,唯一的不同是我们从本地设置中获取授权令牌)。

fetchGps: **function**() {
    **if** (**this**.get("isGpsActivity") && !**this**.get("gps")) {
        **var** model = **this**;
        $.getJSON(
            **this**.url() + "/gps",
            { authorization_token: **this**.settings.authorization_token },
            **function**(data) { model.set("gps", data); }
        );
    }
}

为了触发这个方法,我们将告诉 Backbone.js,每当模型发生变化时,它应该调用fetchGps()方法。

initialize: **function**(attrs, options) {
    **this**.on("change", **this**.fetchGps, **this**);

fetch()响应到达时,Backbone.js 会检测到这种变化,并填充模型,此时我们的代码可以安全地检查isGpsActivity()并发出额外的请求。

步骤 3:让用户切换视图

现在我们的应用程序能够正确显示两种不同的视图,是时候让用户也参与其中了。在这一步中,我们将为他们提供一种轻松的方式在视图之间切换。首先让我们考虑一下Summary视图。如果用户能够点击表格中出现的任何一行并立即跳转到该行的详细视图,那就太好了。

我们的第一个决定是将监听点击事件的代码放在哪里。起初,可能会觉得SummaryRow视图是放置这段代码的自然位置。该视图负责渲染行,因此让该视图处理与行相关的事件似乎是合乎逻辑的。如果我们想这么做,Backbone.js 使得这一切变得非常简单;我们只需要在视图中添加一个额外的属性和一个额外的方法。它们可能看起来像下面这样:

Running.Views.SummaryRow = Backbone.View.extend({
    events: {
        "click": "clicked"
    },
    clicked: **function**() {
        *// Do something to show the Details view for this.model*
    },

events属性是一个对象,列出了我们视图感兴趣的事件。在这种情况下,只有一个事件:click事件。该值——在此情况下是clicked——标识了当事件发生时 Backbone.js 应调用的方法。我们暂时跳过了该方法的细节。

从技术上讲,这种方法没有问题,如果我们继续实现它,可能会很好地工作。然而,它非常低效。想象一个用户在 Nike+上存储了数百条跑步记录。摘要表格将有数百行,每一行都将有自己监听click事件的函数。这些事件处理程序可能会占用大量内存和其他浏览器资源,导致我们的应用变得迟缓。幸运的是,还有一种不同的方法,对浏览器的压力要小得多。

与其为每一行都设置数百个监听click事件的事件处理程序,我们不如使用一个事件处理程序监听所有表格行的点击事件。由于Summary视图负责所有这些行,它是添加该处理程序的自然位置。我们仍然可以利用 Backbone.js 使实现变得简单,只需向视图中添加一个events对象。然而,我们可以做得更好一些。我们不关心表头的click事件,只有表格主体中的行才是我们关心的。通过在事件名称后添加类似 jQuery 的选择器,我们可以将处理程序限制为匹配该选择器的元素。

Running.Views.Summary = Backbone.View.extend({
    events: {
        "click tbody": "clicked"
    },

上述代码要求 Backbone.js 监听我们视图中的<tbody>元素中的click事件。当事件发生时,Backbone.js 将调用我们视图的clicked()方法。

在我们为clicked()方法编写任何代码之前,我们需要一种方法让它弄清楚用户选择了哪个特定的运行模型。事件处理器能够判断用户点击的是哪一行,但它怎么知道那一行代表的是哪个模型呢?为了让处理器能够轻松获取答案,我们可以直接在行的标记中嵌入必要的信息。这需要对我们之前创建的renderRun()方法做一些小调整。

修改后的方法仍然为每个模型创建一个 SummaryRow 视图,渲染该视图并将结果添加到表格主体中。不过,现在我们会在将行添加到页面之前,增加一个额外的步骤。我们为该行添加一个特殊的属性data-id,并将其值设置为模型的唯一标识符。我们使用data-id是因为 HTML5 标准允许任何以data-开头的属性名。这种形式的自定义属性不会违反标准,也不会导致浏览器错误。

renderRun: **function** (run) {
    **var** row = **new** Running.Views.SummaryRow({ model: run });
    row.render();
    row.$el.attr("data-id", run.id);
    **this**.$("tbody").append(row.$el);
},

对于标识符为2126456911的运行,生成的标记大致如下所示:

**<tr** data-id="2126456911"**>**
    **<td>**04/09/2013**</td>**
    **<td>**0:22:39**</td>**
    **<td>**2.33 Miles**</td>**
    **<td>**240**</td>**
    **<td>**9:43**</td>**
**</tr>**

一旦我们确保页面中的标记与 Run 模型有回溯引用,我们就可以在clicked事件处理器中利用这些标记。当 Backbone.js 调用该处理器时,它会传递一个事件对象。从这个对象中,我们可以找到事件的目标。在click事件的情况下,目标就是用户点击的 HTML 元素。

clicked: **function** (ev) {
    **var** $target = $(ev.target)

从前面的标记可以看出,表格行大部分是由表格单元格(<td>元素)组成的,因此表格单元格很可能是click事件的目标。我们可以使用 jQuery 的parents()函数来查找点击目标的父表格行。

clicked: **function** (ev) {
    **var** $target = $(ev.target)
    **var** id = $target.attr("data-id") ||
             $target.parents("[data-id]").attr("data-id");

一旦我们找到了那个父行,我们就提取data-id属性的值。为了安全起见,我们还要处理用户不小心点击表格行本身而不是单个表格单元格的情况。

在获取了属性值之后,我们的视图知道了用户选择了哪个运行模型;现在它需要对这些信息进行处理。可能会有一种冲动让 Summary 视图直接渲染运行模型的 Details 视图,但这样做并不合适。Backbone.js 视图应该只负责自己和它包含的任何子视图。这种做法使得视图可以在各种上下文中安全地复用。例如,我们的 Summary 视图可能会在一个没有 Details 视图的上下文中使用。在这种情况下,直接切换到 Details 视图,充其量会引发错误。

由于总结视图本身不能响应用户点击表格行的操作,它应该遵循应用的层次结构,实际上将信息“传递给上层”。Backbone.js 为这种类型的通信提供了一个方便的机制:自定义事件。总结视图不会直接响应用户点击,而是触发一个自定义事件。其他部分可以监听这个事件并作出相应的响应。如果没有其他代码在监听这个事件,那么什么也不会发生,但至少总结视图可以说它已经完成了自己的工作。

这是我们如何在视图中生成自定义事件:

clicked: **function** (ev) {
    **var** $target = $(ev.target)
    **var** id = $target.attr("data-id") ||
             $target.parents("[data-id]").attr("data-id");
    **this**.trigger("select", id);
}

我们将事件命名为 select,以表明用户选择了一个特定的运行,并将该运行的标识符作为与事件相关的参数传递。到此为止,总结视图已经完成。

应该响应这个自定义事件的组件是最初创建总结视图的组件:我们的应用路由器。我们首先需要监听这个事件。我们可以在 summary() 方法中创建它之后立即进行监听。

Running.Routers.App = Backbone.Router.extend({
    summary: **function**() {
        **this**.runs = **new** Running.Collections.Runs([],
            {authorizationToken: **this**.options.token});
        **this**.runs.fetch();
        **this**.summaryView = **new** Running.Views.Summary({
            collection: **this**.runs});
    $("body").html(**this**.summaryView.render().el);
    **this**.summaryView.on("select", **this**.selected, **this**);
},

当用户从总结视图中选择一个特定的运行时,Backbone.js 会调用我们路由器的 selected() 方法,并将任何事件数据作为参数传入。在我们的例子中,事件数据是唯一标识符,所以它成为该方法的参数。

Running.Routers.App = Backbone.Router.extend({
    selected: **function**(id) {
        **this**.navigate("runs/" + id, { trigger: **true** });
    }

如你所见,事件处理器的代码非常简单。它构造一个对应详细信息视图的 URL("runs/" + id),并将该 URL 传递给路由器的 navigate() 方法。该方法更新浏览器的导航历史记录。第二个参数({ trigger: true })告诉 Backbone.js,假如用户实际导航到该 URL,也要执行相应的操作。由于我们已经设置了 details() 方法来响应 runs/:id 格式的 URL,Backbone.js 会调用 details(),然后我们的路由器将显示所选运行的详细信息。

当用户查看详细信息视图时,我们还希望提供一个按钮,让他们能够轻松地导航到总结视图。与总结视图一样,我们可以为按钮添加一个事件处理器,当用户点击按钮时触发一个自定义事件。

Running.Views.Details = Backbone.View.extend({
    events: {
        "click button": "clicked"
    },
    clicked: **function** () {
        **this**.trigger("summarize");
    }

当然,我们需要在路由器中监听这个自定义事件。

Running.Routers.App = Backbone.Router.extend({
    details: **function**(id) {
        *// Set up the Details view*
        *// Code continues...*
        **this**.detailsView.on("summarize", **this**.summarize, **this**);
    },
    summarize: **function**() {
        **this**.navigate("", { trigger: **true** });
    },

我们再次通过构造适当的 URL 并触发导航来响应用户。

你可能会想,为什么我们必须显式触发导航变化?难道这不是默认行为吗?虽然这看起来合理,但在大多数情况下,这并不合适。我们的应用足够简单,触发路由就能正常工作。然而,更复杂的应用可能希望根据用户是执行了某个操作还是直接导航到特定 URL 来采取不同的行动。处理每种情况的代码最好分开写。在第一种情况下,应用仍然希望更新浏览器的历史记录,但不希望触发完全的导航操作。

第四步:精细调整应用程序

到目前为止,我们的应用程序已经完全功能化。用户可以查看他们的总结,收藏并分享特定运行的详情,并通过浏览器的前进和后退按钮来导航应用程序。然而,在我们宣布它完成之前,还有最后一项清理工作要做。应用程序的性能不是最优的,而且更为关键的是,它存在内存泄漏,即占用了浏览器的小部分内存,却从未释放。

最明显的问题出现在路由器的summary()方法中,具体代码如下:

Running.Routers.App = Backbone.Router.extend({
    summary: **function**() {
        **this**.runs = **new** Running.Collections.Runs([],
            {authorizationToken: **this**.options.token});
        **this**.runs.fetch();
        **this**.summaryView = **new** Running.Views.Summary({
            collection: **this**.runs});
        $("body").html(**this**.summaryView.render().el);
        **this**.summaryView.on("select", **this**.selected, **this**);
    },

每次执行该方法时,它都会创建一个新的集合,获取该集合,并为该集合渲染一个 Summary 视图。显然,第一次执行该方法时,我们必须经过这些步骤,但之后就不需要重复执行了。如果用户选择了特定的运行记录并返回到总结视图,集合或其视图不会发生变化。我们可以在方法中添加一个检查,只有在视图不存在时才执行这些步骤。

summary: **function**() {
    **if** (!**this**.summaryView) {
        **this**.runs = **new** Running.Collections.Runs([],
            {authorizationToken: **this**.options.token});
        **this**.runs.fetch();
        **this**.summaryView = **new** Running.Views.Summary({
            collection: **this**.runs});
        **this**.summaryView.render();
        **this**.summaryView.on("select", **this**.selected, **this**);
    }
    $("body").html(**this**.summaryView.el);
},

我们还可以在details()方法中添加一个检查。当该方法执行并且 Summary 视图存在时,我们可以使用 jQuery 的detach()函数“搁置”Summary 视图的标记。这将保留标记及其事件处理程序,以便用户返回到总结页面时,可以快速重新插入。

details: **function**(id) {
    **if** (**this**.summaryView) {
        **this**.summaryView.$el.detach();
    }
    **this**.run = **new** Running.Models.Run({},
        {authorizationToken: **this**.options.token});
    **this**.run.id = id;
    **this**.run.fetch();
    $("body").html(**this**.detailsView.render().el);
    **this**.detailsView.on("summarize", **this**.summarize, **this**);
},

这些更改使得在 Summary 视图之间的切换更加高效。我们还可以对 Details 视图做类似的优化。在details()方法中,如果运行记录已经存在于集合中,我们就不必重新获取它。我们可以添加一个检查,如果该运行的数据已经可用,就不再进行获取操作。

details: **function**(id) {
    **if** (!**this**.runs || !(**this**.run = **this**.runs.get(id))) {
        **this**.run = **new** Running.Models.Run({},
            {authorizationToken: **this**.options.token});
        **this**.run.id = id;
        **this**.run.fetch();
    }
    **if** (**this**.summaryView) {
        **this**.summaryView.$el.detach();
    }
    **this**.detailsView = **new** Running.Views.Details({model: **this**.run});
    $("body").html(**this**.detailsView.render().el);
    **this**.detailsView.on("summarize", **this**.summarize, **this**);
},

summary()方法中,我们不希望像处理 Summary 视图时那样简单地将 Details 视图搁置一旁。这是因为,如果用户开始查看所有可用的运行记录,可能会有成百上千个 Details 视图存在。因此,我们希望干净地删除 Details 视图,这样浏览器就可以释放该视图占用的内存。

如下代码所示,我们将分三个步骤进行操作。

  1. 移除我们之前为捕捉summarize事件而添加到 Details 视图的事件处理程序。

  2. 调用视图的remove()方法,以释放它所占用的内存。

  3. this.detailsView设置为null,表示该视图不再存在。

summary: **function**() {
    **if** (**this**.detailsView) {
        **this**.detailsView.off("summarize");
        **this**.detailsView.remove();
        **this**.detailsView = **null**;
    }
    **if** (!**this**.summaryView) {
        **this**.runs = **new** Running.Collections.Runs([],
            {authorizationToken: **this**.options.token});
        **this**.runs.fetch();
        **this**.summaryView = **new** Running.Views.Summary({
            collection: **this**.runs});
        **this**.summaryView.render();
        **this**.summaryView.on("select", **this**.selected, **this**);
    }
    $("body").html(**this**.summaryView.el);
},

这样一来,我们的应用程序就完成了!你可以在书籍的源代码中查看最终结果 (jsDataV.is/source/)。

总结

在本章中,我们完成了一个数据驱动的 Web 应用程序。首先,我们看到 Backbone.js 如何让我们灵活地与不完全遵循常规的 REST API 进行交互。接着,我们使用了 Backbone.js 路由器,确保我们的单页面应用像一个完整的网站一样运行,这样我们的用户可以像期望的那样与之交互。

附录 A:更新

请访问 nostarch.com/datavisualization/ 获取更新、勘误及其他信息。

更多实用的书籍来自 NO STARCH PRESS

无标题图片

《雄辩的 JavaScript(第二版)》

现代编程入门

作者:MARIJN HAVERBEKE

2014 年 12 月,472 页,$39.95

ISBN 978-1-59327-584-6

无标题图片

《CSS3 精粹(第二版)》

面向未来的 Web 设计开发者指南

作者:PETER GASSTON

2014 年 11 月,304 页,$34.95

ISBN 978-1-59327-580-8

无标题图片

现代 Web

HTML5、CSS3 和 JavaScript 的多设备 Web 开发

作者:PETER GASSTON

2013 年 4 月,264 页,$34.95

ISBN 978-1-59327-487-0

无标题图片

统计学的错误

完整指南

作者:ALEX REINHART

2015 年 3 月,176 页,$24.95

ISBN 978-1-59327-620-1

无标题图片

面向对象 JavaScript 原理

作者:NICHOLAS C. ZAKAS

2014 年 2 月,120 页,$24.95

ISBN 978-1-59327-540-2

无标题图片

R 编程艺术

统计软件设计之旅

作者:NORMAN MATLOFF

2011 年 10 月,400 页,$39.95

ISBN 978-1-59327-384-2

电话:

800.420.7240 或 415.863.9900

电子邮件:

SALES@NOSTARCH.COM

网站:

WWW.NOSTARCH.COM

posted @ 2025-11-26 09:18  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报