D3-js-数据可视化秘籍-全-

D3.js 数据可视化秘籍(全)

原文:zh.annas-archive.org/md5/1571f6e0e9a2f7682afdc737455b0a69

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

D3.js 是一个 JavaScript 库,旨在以动态图形形式展示数字数据。它帮助你使用 HTML、SVG 和 CSS 使数据生动起来。D3 允许对最终视觉结果有极大的控制,并且它是目前市场上最热门、最强大的基于 Web 的数据可视化技术。

这本书充满了实用的食谱,帮助你学习数据可视化的各个方面。它旨在为你提供所有必要的指导,以便掌握使用 D3 的数据可视化。有了这本书,你将能够借助实用的食谱、插图和代码示例,以专业效率和精度创建令人叹为观止的数据可视化。

这本烹饪书从数据可视化和 D3 基础知识开始,然后逐渐带你通过一系列实用的食谱,涵盖你需要了解的 D3 的广泛主题。

你将学习数据可视化的基本概念、函数式 JavaScript 和 D3 基础知识,包括元素选择、数据绑定、动画和 SVG 生成。你还将学习如何利用更高级的技术,如自定义插值器、自定义缓动、计时器、布局管理器、力操纵等。本书还提供了一些预构建的图表食谱,带有现成的示例代码,帮助你快速启动。

本书涵盖的内容

第一章,D3.js 入门,旨在帮助你快速上手 D3.js。它涵盖了基本方面,例如 D3.js 是什么以及如何设置典型的 D3.js 数据可视化环境。

第二章,选择,教你在使用 D3 进行任何数据可视化项目时需要执行的最基本任务之一——选择。选择帮助你定位页面上的某些视觉元素。

第三章,处理数据,探讨了任何数据可视化项目中最基本的问题——数据如何在编程结构和其视觉隐喻中得以表示。

第四章,倾斜天平,处理数据可视化的重要子领域。作为一名数据可视化开发者,你需要反复执行的一个关键任务是,将数据域中的值映射到视觉域,这正是本章的重点。

第五章,玩转坐标轴,探讨了坐标轴组件的使用以及一些在基于笛卡尔坐标系可视化中常用的相关技术。

第六章,风格化过渡,处理过渡。俗话说“一图胜千言”,这种古老的智慧可以说是数据可视化最重要的基石之一。本章涵盖了 D3 库提供的过渡和动画支持。

第七章, 进入状态,处理可伸缩矢量图形(SVG),这是一个成熟的世界宽频网联盟(W3C)标准,在可视化项目中广泛使用。

第八章, 绘制图表,探讨了数据可视化中最古老且最值得信赖的伴侣之一——图表。图表是对数据进行良好定义和理解的图形表示。

第九章, 布局,专注于 D3 布局。D3 布局是一组算法,用于计算和生成一组能够生成一些最复杂和有趣可视化元素的位置信息。

第十章, 与你的可视化交互,专注于 D3 人类可视化交互支持,换句话说,就是如何给你的可视化添加计算引导能力。

第十一章, 使用力,涵盖了 D3 最迷人的方面之一——力。力模拟是你可以为你的可视化添加的最令人敬畏的技术之一。

第十二章, 了解你的地图,介绍了基本的 D3 地图可视化技术和如何在 D3 中实现一个功能齐全的地理可视化。

第十三章, 测试你的可视化,教你如何使用测试驱动开发(TDD)像专业人士一样实现你的可视化。

附录 A, 几分钟内构建交互式分析,是关于交互式维度图表的 Crossfilter.js 和 dc.js 的介绍。

你需要这本书的内容

  • 文本编辑器:用于编辑和创建 HTML、CSS 和 JavaScript 文件

  • 网络浏览器:现代网络浏览器(Firefox 3、IE 9、Chrome、Safari 3.2 及以上版本)

  • 本地 HTTP 服务器:您需要本地 HTTP 服务器来托管本书中一些更高级食谱的数据文件。我们将在第一章中介绍如何设置基于 Node 或 Python 的简单 HTTP 服务器。

  • Git 客户端(可选):如果您想直接从我们的 Git 仓库检查食谱源代码,您需要在您的计算机上安装 Git 客户端。

适用于本书的读者

如果你是一位熟悉 HTML、CSS 和 JavaScript 的开发者或分析师,并且希望从 D3 中获得最大收益,那么这本书适合你。这本书还可以作为经验丰富的数据可视化开发者的桌面快速参考指南。

习惯用法

在这本书中,您将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码词如下所示:“我们可以通过使用d3.select函数来选择 HTML 元素。”

代码块设置如下:

instance.description = function (d) {
    if (!arguments.length) d;
    description = d;
    return instance;
};

当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:

instance.description = function (d) {
    if (!arguments.length) d;
    description = d;
    return instance;
};

任何命令行输入或输出都按如下方式编写:

> npm install http-server –g

新术语重要词汇将以粗体显示。你在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“点击下一步按钮将你移动到下一屏幕”。

注意

警告或重要提示将以如下框中的形式出现。

小贴士

小技巧和窍门看起来像这样。

读者反馈

读者反馈始终欢迎。请告诉我们你对这本书的看法——你喜欢什么或可能不喜欢什么。读者反馈对我们开发你真正能从中获得最大收益的标题非常重要。

要发送给我们一般反馈,只需发送电子邮件到<feedback@packtpub.com>,并在邮件主题中提及书名。

如果你在某个领域有专业知识,并且对撰写或参与书籍感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在你已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助你从购买中获得最大收益。

下载示例代码

你可以从你购买的所有 Packt 图书的账户中下载示例代码文件www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便直接将文件通过电子邮件发送给你。

错误清单

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果你在我们的书中发现错误——可能是文本或代码中的错误——如果你能向我们报告这一点,我们将不胜感激。通过这样做,你可以帮助其他读者避免挫折,并帮助我们改进本书的后续版本。如果你发现任何错误清单,请通过访问www.packtpub.com/submit-errata来报告它们,选择你的书籍,点击错误清单提交表链接,并输入你的错误清单详情。一旦你的错误清单得到验证,你的提交将被接受,错误清单将被上传到我们的网站,或添加到该标题的错误清单部分。任何现有的错误清单都可以通过从www.packtpub.com/support选择你的标题来查看。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果你在互联网上发现我们作品的任何非法副本,无论形式如何,请立即向我们提供位置地址或网站名称,以便我们可以追究补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者以及为我们提供有价值内容方面的帮助。

问题

如果你在本书的任何方面遇到问题,可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决。

第一章. 使用 D3.js 入门

本章我们将涵盖:

  • 设置简单的 D3 开发环境

  • 设置基于 NPM 的开发环境

  • 理解 D3 风格的 JavaScript

简介

本章旨在帮助您开始使用 D3.js,涵盖基本方面,例如 D3.js 是什么,以及如何设置典型的 D3.js 数据可视化环境。本章还特别介绍了一些 D3.js 依赖的、不太为人所知的 JavaScript 领域。

什么是 D3?D3 指的是 数据驱动文档,根据官方 D3 Wiki:

D3.js 是一个基于数据的 JavaScript 库,用于操作文档。D3 帮助您使用 HTML、SVG 和 CSS 使数据生动起来。D3 对网络标准的强调让您能够充分利用现代浏览器的全部功能,而不必绑定到任何专有框架,结合强大的可视化组件和数据驱动的 DOM 操作方法。

D3 Wiki (2013, 八月)

在某种意义上,D3 是一个专门的 JavaScript 库,它允许您通过利用现有的网络标准,以更简单(数据驱动)的方法创建惊人的数据可视化。D3.js 由 Mike Bostock (bost.ocks.org/mike/) 创建,并取代了他之前在名为 Protovis 的不同 JavaScript 数据可视化库上的工作。有关 D3 的创建以及影响 Protovis 和 D3.js 的理论的信息,请参阅以下信息框中的链接。在本书中,我们将更多地关注如何使用 D3.js 来驱动您的可视化。最初,由于 D3 在使用 JavaScript 进行数据可视化方面采用了不同的方法,一些方面可能会有些令人困惑。我希望在本书的整个过程中,大量的话题,从基础到高级,都将使您对 D3 感到舒适和有效。一旦正确理解,D3 可以通过数量级的方式提高您在数据可视化方面的生产力和表现力。

注意

要更正式地了解 D3 背后的理念,请参阅 Mike Bostock 在 IEEE InfoVis 2010 上发表的 Declarative Language Design for Interactive Visualization 论文,网址为 vis.stanford.edu/papers/protovis-design

如果您想了解 D3 是如何产生的,我建议您查看 Mike Bostock 在 IEEE InfoVis 2011 上发表的 D3: Data-Driven Document 论文,网址为 vis.stanford.edu/papers/d3

D3.js 的前身 Protovis,也是由 Mike Bostock 和斯坦福可视化小组的 Jeff Heer 创建,可以在 mbostock.github.io/protovis/ 找到。

设置简单的 D3 开发环境

开始一个由 D3 驱动的数据可视化项目时,你首先需要的是一个工作开发环境。在本食谱中,我们将向您展示如何在几分钟内设置一个简单的 D3 开发环境。

准备工作

在我们开始之前,请确保你已经安装并准备好你喜欢的文本编辑器。

如何操作...

我们将首先下载 D3.js:

  1. d3js.org/下载 D3.js 的最新稳定版本。你可以从github.com/mbostock/d3/tags下载存档的旧版本。此外,如果你有兴趣尝试 master 分支上最新的 D3 构建,你可以分叉github.com/mbostock/d3

  2. 下载并解压后,你将在提取的文件夹中找到三个文件 d3.v3.jsd3.v3.min.js 以及其许可证。对于开发,建议使用 d3.v3.js,即“未压缩”版本,因为它可以帮助你在 D3 库内部追踪和调试 JavaScript。一旦提取,将 d3.v3.js 文件放置在与包含以下 HTML 的 index.html 文件相同的文件夹中:

    <!-- index.html -->
    <!DOCTYPE html>
    <html>
    <head>
        <meta charset="utf-8">
        <title>Simple D3 Dev Env</title>
        <script type="text/javascript" src="img/d3.v3.js"></script>
    </head>
    <body>
    
    </body>
    </html>
    

提示

如果你从源或标记版本下载 D3,JavaScript 文件名将略有不同。它将简单地被称为 d3.js 而不是 d3.v3.js

这就是创建一个最简单的 D3 驱动的数据可视化开发环境所需的所有内容。有了这个设置,你可以基本上使用你喜欢的文本编辑器打开 HTML 文件开始开发,也可以通过在浏览器中打开文件来查看你的可视化。

注意

本菜谱的源代码可以在github.com/NickQiZhu/d3-cookbook/tree/master/src/chapter1/simple-dev-env找到。

它是如何工作的...

D3 JavaScript 库非常自给自足。它不依赖于任何浏览器已经提供的 JavaScript 库。实际上,它甚至可以在非浏览器环境(如 Node.js)中使用,只需进行一些基本的设置(我将在后面的章节中更详细地介绍这一点)。

提示

如果你的可视化目标浏览器环境包括 Internet Explorer 9,建议使用兼容性库 Aight,它可以在github.com/shawnbot/aight找到,以及 Sizzle 选择器引擎sizzlejs.com/

在头部部分包含以下字符编码指令是至关重要的:

    <meta charset="utf-8">

字符编码指示浏览器和验证器在渲染网页时使用哪些字符集。否则,由于 D3 使用 utf-8 字符集来表示某些符号(如 π),你的浏览器将无法加载 D3 JavaScript 库。

注意

D3 是完全开源的,并且它是在其作者 Michael Bostock 创建的自定义许可协议下开源的。这个许可协议与流行的 MIT 许可证非常相似,只有一个例外,即它明确指出,未经许可,不得使用 Michael Bostock 的名字来认可或推广由此软件派生的产品。

还有更多...

在整个食谱集中,将提供多个食谱代码示例。所有示例源代码都提供并托管在 GitHub(github.com/)这个流行的开源社交编码仓库平台上。

如何获取源代码

获取所有所需食谱源代码的最简单方法是通过克隆这本书的 Git 仓库(github.com/NickQiZhu/d3-cookbook)。如果你不打算为食谱设置开发环境,那么你可以安全地跳过这一节。

小贴士

如果你不太熟悉 Git,克隆的概念类似于其他版本控制软件中的检出(check-out)概念。然而,克隆不仅仅只是检出文件,它还会将所有分支和版本历史复制到你的本地机器上,实际上是将整个仓库克隆到你的本地机器,这样你就可以在这个克隆的仓库中完全离线工作。

首先,在你的电脑上安装一个 Git 客户端。你可以在git-scm.com/downloads找到 Git 客户端软件列表,以及如何在不同的操作系统上安装它的详细指南git-scm.com/book/en/Getting-Started-Installing-Git

小贴士

另一种使 Git 和 GitHub 工作起来更流行的方式是安装 GitHub 客户端,它比单纯的 Git 提供了更丰富的功能。然而,在撰写本文时,GitHub 只为 Windows 和 Mac OS 提供了客户端软件。

GitHub for Windows: windows.github.com/.

GitHub for Mac: mac.github.com/.

一旦安装了 Git 客户端,只需执行以下命令即可将所有食谱源代码下载到你的电脑上:

> git clone git://github.com/NickQiZhu/d3-cookbook.git

小贴士

或者,如果你选择使用 GitHub 客户端,只需在仓库页面github.com/NickQiZhu/d3-cookbook上点击Fork按钮。这将使这个仓库出现在你的 GitHub 客户端中。

设置基于 NPM 的开发环境

当你在进行一个需要使用多个 JavaScript 库的更复杂的数据可视化项目时,我们之前讨论的简单解决方案可能会变得有些笨拙和难以操作。在本节中,我们将展示一个使用Node Packaged ModulesNPM)的改进设置——这是一个事实上的 JavaScript 库仓库管理系统。如果你和我一样急切,想要直接进入书的核心部分——食谱,你可以安全地跳过这一节,在你需要为项目设置一个更成熟的运行环境时再回来。

准备工作

在我们开始之前,请确保你已经正确安装了 NPM。NPM 是 Node.js 安装的一部分。你可以从 nodejs.org/download/ 下载 Node.js。选择适合你的操作系统的正确 Node.js 二进制构建。一旦安装,npm 命令将在你的终端控制台中可用。

> npm -v 
1.2.14

上述命令将打印出你的 NPM 客户端版本号,表明安装成功。

如何做到这一点...

在安装了 NPM 之后,现在我们可以创建一个包描述文件来自动化一些手动设置步骤。

  1. 首先,在你的项目文件夹下创建一个名为 package.json 的文件,包含以下代码:

    {
      "name": "d3-project-template",
      "version": "0.1.0",
      "description": "Ready to go d3 data visualization project template",
      "keywords": [
        "data visualization",
        "d3"
      ],
      "homepage": "<project home page>",
      "author": {
        "name": "<your name>",
        "url": "<your url>"
      },
      "repository": {
        "type": "git",
        "url": "<source repo url>"
      },
      "dependencies": {
          "d3":"3.x"
      },
      "devDependencies": {
          "uglify-js": "2.x"
      }
    }
    
  2. 一旦定义了 package.json 文件,你就可以简单地运行:

    > npm install
    

它是如何工作的...

package.json 文件中的大多数字段仅用于信息目的,例如名称、描述、主页、作者和仓库。如果你决定将来将你的库发布到 NPM 仓库,将使用名称和版本字段。目前我们真正关心的是 dependenciesdevDependencies 字段。

  • dependencies 字段描述了你的项目在浏览器中正常运行所需的运行时库依赖,这意味着你的项目需要的库。在这个简单的例子中,我们只有一个对 d3 的依赖。d3 是 D3 库在 NPM 仓库中发布的名称。版本号 3.x 表示该项目与任何 3 版本兼容,并且 NPM 应该检索最新的稳定版本 3 构建来满足这个依赖。

    小贴士

    D3 是一个自给自足的库,没有外部运行时依赖。然而,这并不意味着它不能与其他流行的 JavaScript 库一起工作。我经常使用 D3 与其他库一起工作,以使我的工作更简单,例如 JQuery、Zepto.js、Underscore.js 和 Backbone.js。

  • devDependencies 字段描述了开发时间(编译时间)库依赖。这意味着,在这个类别下指定的库仅用于构建此项目,而不是运行你的 JavaScript 项目所必需的。

注意

详细 NPM 包 JSON 文件文档可以在 npmjs.org/doc/json.html 找到。

执行 npm install 命令将自动触发 NPM 下载你的项目所需的所有依赖项,包括你的依赖项的依赖项。所有依赖库都将下载到你的项目根目录下的 node_modules 文件夹中。完成此操作后,你只需创建一个 HTML 文件,就像在之前的菜谱中展示的那样,并直接从 node_modules/d3/d3.js 加载你的 D3 JavaScript 库。

这个菜谱的源代码以及自动构建脚本可以在 github.com/NickQiZhu/d3-cookbook/tree/master/src/chapter1/npm-dev-env 找到。

依赖 NPM 是一种简单而有效的方法,可以让你免于手动下载 JavaScript 库的所有麻烦,以及不断保持它们更新的需求。然而,一个敏锐的读者可能已经注意到,有了这种力量,我们可以轻松地将我们的环境设置提升到下一个层次。想象一下,如果你正在构建一个大型可视化项目,其中将创建数千行 JavaScript 代码,显然我们这里描述的简单设置就不再足够了。然而,模块化 JavaScript 开发本身就可以填满一本书;因此,我们不会尝试涵盖这个主题,因为我们的重点是数据可视化和 D3。如果你对此感兴趣,请参考这个菜谱的源代码,其中展示了如何在简单自动化构建脚本的之上实现更模块化的方法。在后面的章节中,当讨论与单元测试相关的菜谱时,我们将扩展这个主题的范围,以展示如何增强我们的设置以运行自动化单元测试。

还有更多...

尽管在前面的章节中提到,你可以直接使用浏览器打开你创建的 HTML 页面来查看你的可视化结果,但这种方法确实有其局限性。一旦我们需要从单独的数据文件中加载数据(这是我们将在后面的章节中做的,也是你日常工作中最可能的情况),由于浏览器的内置安全策略,这种简单的方法就会失效。为了绕过这个安全限制,强烈建议你设置一个本地 HTTP 服务器,这样你的 HTML 页面和数据文件就可以从这个服务器上提供服务,而不是直接从文件系统中加载。

设置本地 HTTP 服务器

根据你使用的操作系统和你决定使用的软件包来充当 HTTP 服务器,可能有一打方法在你的电脑上设置 HTTP 服务器。在这里,我将尝试涵盖一些最受欢迎的设置。

Python 简单 HTTP 服务器

这是我最喜欢的用于开发和快速原型制作的方法。如果你已经在你的操作系统上安装了 Python,这对于任何 Unix/Linux/Mac OS 发行版来说通常是情况,你只需在终端中输入这个命令即可:

> python –m SimpleHTTPServer 8888

或者使用更新的 Python 发行版:

> python –m http.server 

这个小巧的 Python 程序将启动一个 HTTP 服务器,并从程序启动的文件夹开始服务任何文件。这可能是迄今为止在任意操作系统上运行 HTTP 服务器最简单的方法。

注意

如果你还没有在电脑上安装 Python,你可以从 www.python.org/getit/ 获取它。它适用于所有现代操作系统,包括 Windows、Linux 和 Mac。

Node.js HTTP 服务器

如果你已经安装了 Node.js,也许是我们之前章节中进行的开发环境设置练习的一部分,那么你可以简单地安装 http-server 模块。类似于 Python Simple HTTP Server,这个模块将允许你从任何文件夹启动一个轻量级 HTTP 服务器,并立即开始服务页面。

首先安装 http-server 模块:

> npm install http-server –g

在这个命令中,-g 选项会将 http-server 模块全局安装,这样它就会自动在你的命令行终端中可用。一旦完成,你就可以通过简单地输入以下命令从你所在的任何文件夹启动服务器:

> http-server .

此命令将在默认端口 8080 上启动一个由 Node.js 驱动的 HTTP 服务器,或者如果你想的话,可以使用 –p 选项为它提供一个自定义的端口号。

小贴士

如果你正在 Linux/Unix/Mac OS 上运行 npm install 命令,你需要以 sudo 模式或作为 root 运行命令,才能使用 –g 全局安装选项。

理解 D3 风格的 JavaScript

D3 是使用函数式风格的 JavaScript 设计和构建的,这可能对更习惯于过程式或面向对象 JavaScript 风格的人来说显得陌生甚至不熟悉。本食谱旨在涵盖 D3 中一些最基本的概念,这些概念对于理解 D3 至关重要,并且还能让你能够以 D3 风格编写可视化代码。

准备工作

在你的网络浏览器中打开以下文件的本地副本:[github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter1/functional-js.html](http:// https://github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter1/functional-js.html)

如何做...

让我们深入探讨 JavaScript 的优点——更函数式的一面。看看以下代码片段:

function SimpleWidget(spec) {
  var instance = {}; // <-- A

  var headline, description; // <-- B

  instance.render = function () {
    var div = d3.select('body').append("div");

    div.append("h3").text(headline); // <-- C

    div.attr("class", "box")
    .attr("style", "color:" + spec.color) // <-- D
      .append("p")
      .text(description); // <-- E

    return instance; // <-- F
  };

  instance.headline = function (h) {
    if (!arguments.length) h; // <-- G
      headline = h;
    return instance; // <-- H
  };

  instance.description = function (d) {
    if (!arguments.length) d;
      description = d;
    return instance;
  };

  return instance; // <-- I
}

  var widget = SimpleWidget({color: "#6495ed"})
    .headline("Simple Widget")
    .description("This is a simple widget demonstrating functional javascript.");
  widget.render();

这段代码片段在你的网页上生成以下简单的小部件:

如何做...

一个使用函数式 JavaScript 的简单小部件

它是如何工作的...

尽管这个小部件的界面很简单,但它与 D3 风格的 JavaScript 有着不可否认的相似性。这不是巧合,而是通过利用一种名为函数式对象的 JavaScript 编程范式来实现的。像许多有趣的话题一样,这也是一个可以单独填满一本书的话题;尽管如此,我仍将尝试在本节中涵盖这个特定范式最重要的和最有用的方面,这样你,作为读者,不仅能够理解 D3 的语法,还能够以这种方式创建一个库。正如 D3 项目 Wiki 上所述,这种函数式编程风格给了 D3 很大的灵活性:

D3 的函数式风格通过一系列组件和插件允许代码重用。

D3 Wiki (2013, 八月)

函数是对象

JavaScript 中的函数是对象。就像任何其他对象一样,函数只是一个名称和值对的集合。函数对象与普通对象之间的唯一区别是函数可以被调用,并且还关联着两个隐藏属性:函数上下文和函数代码。这可能会让人感到惊讶和不自然,尤其是如果你来自更注重过程式编程的背景。尽管如此,这却是我们大多数人需要的关键洞察,以便理解 D3 使用函数的一些奇怪方式。

注意

现在的 JavaScript 通常被认为不是非常面向对象,然而,函数对象可能是它超越其他一些更面向对象的同类的方面之一。

现在有了这个洞察,让我们再次看看代码片段:

  var instance = {}; // <-- A

  var headline, description; // <-- B

  instance.render = function () {
    var div = d3.select('body').append("div");

    div.append("h3").text(headline); // <-- C

    div.attr("class", "box")
      .attr("style", "color:" + spec.color) // <-- D
      .append("p")
      .text(description); // <-- E

    return instance; // <-- F
  };

在标记为 A、B 和 C 的行中,我们可以清楚地看到 instanceheadlinedescription 都是 SimpleWidget 函数对象的内部私有变量。而 render 函数是与 instance 对象关联的函数,该对象本身被定义为对象字面量。由于函数只是一个对象,它也可以存储在对象/函数、其他变量、数组中,并且可以作为函数参数传递。函数 SimpleWidget 执行的结果是在 I 行返回对象实例。

function SimpleWidget(spec) {
...
  return instance; // <-- I
}

注意

render 函数使用了我们尚未介绍的一些 D3 函数,但在这里我们不必过多关注它们,因为我们将在接下来的几章中深入探讨每个函数。此外,它们基本上只是渲染这个小部件的视觉表示,与我们当前的主题关系不大。

小贴士

下载示例代码

您可以从您在 www.packtpub.com 的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。

静态变量作用域

现在好奇的读者可能正在问,在这个例子中变量作用域是如何解决的,因为渲染函数似乎可以访问instanceheadlinedescription变量,甚至传递给基本SimpleWidget函数的spec变量。这种看似奇怪的作用域实际上是受一个简单的静态作用域规则决定的。这个规则可以这样理解:在搜索变量引用时,首先在本地执行变量搜索。当找不到变量声明(如行 C 上的headline)时,搜索将继续到父对象(在这种情况下,SimpleWidget函数是其静态父对象,headline变量声明在行 B 上找到)。如果仍然找不到,则此过程将递归地继续到下一个静态父对象,依此类推,直到达到全局变量定义,如果仍然找不到,则为此变量生成一个引用错误。这种作用域行为与一些最流行的语言(如 Java 和 C#)中的变量解析规则非常不同;这可能需要一些时间来适应,但是如果你仍然觉得困惑,不要过于担心。通过更多的实践并牢记静态作用域规则,你很快就会适应这种作用域。

提示

这里有一个需要注意的地方——同样适用于来自 Java 和 C# 背景的开发者——JavaScript 不实现块作用域。我们描述的静态作用域规则仅适用于函数/对象,而不适用于块级别。

for(var i = 0; i < 10; i++){
  for(var i = 0; i < 2; i++){
    console.log(i);
  }
}

提示

你可能会认为这段代码应该生成 20 个数字。然而,在 JavaScript 中,这段代码会创建一个无限循环。这是因为 JavaScript 不实现块作用域,所以内层循环中的i与外层循环中使用的i是同一个。因此,它会被内层循环重置,从而永远无法结束外层循环。

与更流行的基于原型的伪经典模式相比,这种模式通常被称为功能模式。功能模式的优势在于它提供了一个更好的机制来实现信息隐藏和封装,因为私有变量——在我们的例子中是headlinedescription变量——只能通过嵌套函数通过静态作用域规则访问,因此SimpleWidget函数返回的对象既灵活又更难以篡改和损坏。

如果我们以函数式风格创建一个对象,并且如果该对象的所有方法都不使用这个变量,那么这个对象就是持久的。持久对象只是作为能力集合的函数集合。

(Crockfort D. 2008)

变量参数函数

在线 G 上发生了一些奇怪的事情:

instance.headline = function (h) {
  if (!arguments.length) h; // <-- G
  headline = h;
  return instance; // <-- H
};

你可能想知道第 G 行的 arguments 变量是从哪里来的。在这个例子中,它从未在任何地方被定义过。arguments 变量是一个内置的隐藏参数,当函数被调用时,它对函数是可用的。arguments 变量包含了一个函数调用的所有参数的数组。

小贴士

事实上,arguments 并不是一个真正的 JavaScript 数组对象。它有长度并且可以通过索引访问,然而它并没有与典型 JavaScript 数组对象相关联的许多方法,例如 sliceconcat。当你需要在 arguments 上使用标准的 JavaScript 数组方法时,你需要使用 apply 调用模式:

var newArgs = Array.prototype.slice.apply(arguments);

这个隐藏参数与 JavaScript 中省略函数参数的能力结合使用,允许你编写一个像 instance.headline 这样的函数,它具有未指定的参数数量。在这种情况下,我们既可以有一个参数 h,也可以没有。因为当没有传递参数时,arguments.length 返回 0;因此,如果没有传递参数,headline 函数返回 h,否则如果提供了参数 h,它就变成了一个设置器。为了澄清这个解释,让我们看看以下代码片段:

var widget = SimpleWidget({color: "#6495ed"})
    .headline("Simple Widget"); // set headline
console.log(widget.headline()); // prints "Simple Widget"

这里你可以看到如何使用不同的参数将标题函数用作设置器和获取器。

函数链接

这个特定示例的下一个有趣方面是函数相互链接的能力。这也是 D3 库主要采用的函数调用模式,因为大多数 D3 函数都是设计成可链接的,以提供更简洁和上下文相关的编程接口。一旦你理解了可变参数函数的概念,这实际上是非常简单的。由于可变参数函数——例如 headline 函数——可以同时作为设置器和获取器,那么当它作为设置器返回 instance 对象时,允许你立即在调用结果上调用另一个函数;这就是链接。

让我们看看以下代码:

var widget = SimpleWidget({color: "#6495ed"})
  .headline("Simple Widget")
  .description("This is ...")
  .render();

在这个例子中,SimpleWidget 函数返回 instance 对象(如第 I 行所示)。然后,headline 函数作为设置器被调用,它也返回 instance 对象(如第 H 行所示)。然后可以直接在返回值上调用 description 函数,它再次返回 instance 对象。然后最后调用 render 函数。

现在我们已经了解了功能 JavaScript 和一个现成的 D3 数据可视化开发环境,我们准备深入探索 D3 提供的丰富概念和技术。然而在我们起飞之前,我想再覆盖几个重要领域——如何寻找和分享代码,以及当你遇到困难时如何寻求帮助。

还有更多...

让我们看看一些额外的有用资源。

寻找和分享代码

与其他可视化选项相比,D3 的一个优点是它提供了丰富的示例和教程,您可以从中汲取灵感。在创建我自己的开源可视化图表库和本书的过程中,我大量借鉴了这些资源。我将列出一些在这个方面最受欢迎的选项。这个列表绝对不是全面的目录,而是一个供您探索的起点:

  • D3 画廊 (github.com/mbostock/d3/wiki/Gallery) 包含了一些您可以在网上找到的关于 D3 使用的最有趣的示例。它包括不同可视化图表的示例、特定技术以及一些有趣的野外可视化实现等。

  • BioVisualize(http://biovisualize.github.io/d3visualization 是另一个带有分类的 D3 画廊,可以帮助您快速在线找到所需的可视化示例。

  • D3 教程页面 (github.com/mbostock/d3/wiki/Tutorials) 收集了各种贡献者在不同时间创建的教程、演讲和幻灯片,详细展示了如何使用许多 D3 概念和技术。

  • D3 插件 (github.com/d3/d3-plugins). 可能 D3 对于您的可视化需求缺少一些功能?在您决定实现自己的功能之前,请务必查看 D3 插件仓库。它包含了许多插件,这些插件提供了可视化领域中一些常见和有时不常见的功能。

  • D3 API (github.com/mbostock/d3/wiki/API-Reference) 非常详细地记录了。在这里,您可以找到 D3 库提供的每个函数和属性的详细解释。

  • Mike Bostock 的 Blocks (bl.ocks.org/mbostock) 是一个 D3 示例网站,其中可以找到一些更引人入胜的可视化示例,并由其作者 Mike Bostock 维护。

  • JS Bin (jsbin.com/ugacud/1/edit) 是一个完全在线托管的前置 D3 测试和实验环境。您可以使用这个工具轻松地原型化一个简单的脚本,或者与社区中的其他成员分享您的创作。

  • JS Fiddle (jsfiddle.net/qAHC2/) 与 JS Bin 类似;它也是一个在线托管 JavaScript 代码原型和共享平台。

如何获取帮助

即使有所有这些示例、教程和食谱,当你在创建可视化时可能仍然会遇到挑战。好消息是,D3 拥有一个广泛且活跃的支持社区。简单地“谷歌”你的问题通常可以找到令人满意的答案。即使没有,也不要担心;D3 拥有一个强大的基于社区的支撑:

第二章:选择之道

在本章中,我们将涵盖:

  • 选择单个元素

  • 选择多个元素

  • 遍历选择集

  • 执行子选择

  • 函数链式调用

  • 操作原始选择集

简介

在使用 D3 的任何数据可视化项目中,你需要执行的最基本任务之一就是选择。选择可以帮助你定位页面上的某些视觉元素。如果你已经熟悉 W3C 标准化的 CSS 选择器或由流行的 JavaScript 库(如 jQuery 和 Zepto.js)提供的其他类似选择器 API,那么你将发现自己在 D3 的选择器 API 中如鱼得水。如果你之前没有使用选择器 API,请不要担心,本章旨在通过一些非常直观的食谱分步骤介绍这个主题;它将涵盖你数据可视化需求的所有常见用例。

引入选择集:选择器支持已被 W3C 标准化,因此所有现代网络浏览器都内置了对选择器 API 的支持。然而,当涉及到网络开发时,尤其是在数据可视化领域,基本的 W3C 选择器 API 存在局限性。标准的 W3C 选择器 API 只提供选择器,而不提供选择集。这意味着选择器 API 帮助你在文档中选择元素,但是,要操作所选元素,你仍然需要遍历每个元素,以便操作所选元素。考虑以下使用标准选择器 API 的代码片段:

var i = document.querySelectorAll("p").iterator();
var e;
while(e = i.next()){
  // do something with each element selected
  console.log(e);
}

上述代码实际上选择了文档中的所有 <p> 元素,然后遍历每个元素以执行某些任务。这显然会很快变得繁琐,尤其是在你需要在页面上不断操作许多不同元素时,这是我们通常在数据可视化项目中做的事情。这就是为什么 D3 引入了它自己的选择器 API,使得开发工作不再那么枯燥。在本章的剩余部分,我们将介绍 D3 的选择器 API 的工作原理以及一些其强大的功能。

CSS3 选择器基础:在我们深入 D3 的选择器 API 之前,需要对 W3C 第 3 级选择器 API 进行一些基本介绍。如果你已经熟悉 CSS3 选择器,请随意跳过本节。D3 的选择器 API 是基于第 3 级选择器构建的,或者更常见的是 CSS3 选择器支持。在本节中,我们计划介绍一些必须了解 D3 选择器 API 的最常见 CSS3 选择器语法。

  • #foo:选择 id 值为 foo 的元素

    <div id="foo">
    
  • foo:选择元素 foo

    <foo>
    
  • .foo:选择 class 值为 foo 的元素

    <div class="foo">
    
  • [foo=goo]:选择具有 foo 属性值并将其设置为 goo 的元素

    <div foo="goo">
    
  • foo goo:选择 foo 元素内部的 goo 元素

    <foo><goo></foo>
    
  • foo#goo:选择 id 值为 goofoo 元素

    <foo id="goo">
    
  • foo.goo:选择 class 值为 goofoo 元素

    <foo class="goo">
    
  • foo:first-child:选择 foo 元素的第一个子元素

    <foo> // <-- this one
    <foo>
    <foo> 
    
  • foo:nth-child(n): 选择foo元素的第 n 个子元素

    <foo>
    <foo> // <-- foo:nth-child(2)
    <foo> // <-- foo:nth-child(3)
    

CSS3 选择器是一个相当复杂的话题。在这里,我们只列出了一些你需要理解和掌握的、在用 D3 工作时最常用的选择器。有关这个主题的更多信息,请访问 W3C 的 3 级选择器 API 文档www.w3.org/TR/css3-selectors/

小贴士

如果你正在针对不支持选择器的旧浏览器,你可以在 D3 之前包含 Sizzle 以实现向后兼容性。你可以在sizzlejs.com/找到 Sizzle。

目前,下一代选择器 API 的 4 级在 W3C 处于草案阶段。你可以在dev.w3.org/csswg/selectors4/查看它提供的内容及其当前草案。

主要浏览器厂商已经开始实现一些 4 级选择器,如果你想知道浏览器支持的程度,可以尝试这个实用的网站css4-selectors.com/browser-selector-test/

选择单个元素

有时,你可能需要在一个页面上选择单个元素以执行一些视觉操作。这个示例将向你展示如何使用 CSS 选择器在 D3 中执行有针对性的单个元素选择。

准备工作

在你的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter2/single-selection.html

如何操作...

让我们选择一些内容(比如一个段落元素)并在屏幕上生成经典的“hello world”。

<p id="target"></p> <!-- A -->

<script type="text/javascript">
 d3.select("p#target") // <-- B
 .text("Hello world!"); // <-- C
</script>

这个示例简单地在你的屏幕上显示一个Hello world!

工作原理...

d3.select命令用于在 D3 中执行单个元素选择。此方法接受一个表示有效 CSS3 选择器的字符串,或者如果你已经有了要选择的元素的引用,则可以接受一个元素对象。d3.select命令返回一个 D3 选择对象,你可以在此对象上链式调用修改函数来操作此元素的属性、内容或内部 HTML。

小贴士

使用提供的选择器可以选择多个元素,但只返回选择集中的第一个元素。

在这个例子中,我们在 B 行选择具有target作为id值的段落元素,然后在 C 行将其文本内容设置为Hello world!。所有 D3 选择都支持一组标准修改函数。我们在这里展示的text函数就是其中之一。以下是在本书中你将遇到的一些最常见的修改函数:

  • selection.attr函数:此函数允许你检索或修改所选元素上的给定属性。

    // set foo attribute to goo on p element
    d3.select("p").attr("foo", "goo"); 
    // get foo attribute on p element
    d3.select("p").attr("foo");
    
  • selection.classed函数:此函数允许你在所选元素上添加或删除 CSS 类。

    // test to see if p element has CSS class goo
    d3.select("p").classed("goo");
    // add CSS class goo to p element
    d3.select("p").classed("goo", true);
    // remove CSS class goo from p element. classed function
    // also accepts a function as the value so the decision 
    // of adding and removing can be made dynamically
    d3.select("p").classed("goo", function(){return false;});
    
  • selection.style 函数:此函数允许你将具有特定名称的 CSS 样式设置为选定的元素(们)的特定值。

    // get p element's style for font-size
    d3.select("p").style("font-size");
    // set font-size style for p to 10px
    d3.select("p").style("font-size", "10px");
    // set font-size style for p to the result of some 
    // calculation. style function also accepts a function as // the value can be produced dynamically
    d3.select("p"). style("font-size", function(){return normalFontSize + 10;});
    
  • selection.text 函数:此函数允许你访问和设置选定的元素(们)的文本内容。

    // get p element's text content
    d3.select("p").text();
    // set p element's text content to "Hello"
    d3.select("p").text("Hello");
    // text function also accepts a function as the value, 
    // thus allowing setting text content to some dynamically 
    // produced message
    d3.select("p").text(function(){
      var model = retrieveModel();
      return model.message;
    });
    
  • selection.html 函数:此函数允许你修改元素的内部 HTML 内容。

    // get p element's inner html content
    d3.select("p").html();
    // set p element's inner html content to "<b>Hello</b>"
    d3.select("p").text("<b>Hello</b>");
    // html function also accepts a function as the value, 
    // thus allowing setting html content to some dynamically 
    // produced message
    d3.select("p").text(function(){
      var template = compileTemplate();
      return template();
    });
    

    这些修饰函数适用于单元素和多元素选择结果。当应用于多元素选择时,这些修改将应用于每个选定的元素。我们将在本章剩余部分的其他更复杂的食谱中看到它们的应用。

    注意

    当一个函数在这些修饰函数中用作值时,实际上有一些内置参数被传递给这些函数,以启用数据驱动的计算。这种数据驱动的方法赋予了 D3 其力量和其名称(数据驱动文档),将在下一章中详细讨论。

选择多个元素

通常选择单个元素是不够的,你更希望同时对页面上的元素集应用某些更改。在这个食谱中,我们将使用 D3 多元素选择器和其选择 API 进行操作。

准备工作

在你的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter2/multiple-selection.html

如何做到这一点...

这正是 d3.selectAll 函数的设计目的。在这个代码片段中,我们将选择三个不同的 div 元素,并使用一些 CSS 类增强它们。

<div></div>
<div></div>
<div></div>

<script type="text/javascript">
 d3.selectAll("div") // <-- A
 .attr("class", "red box"); // <-- B
</script>

这段代码片段生成了以下视觉效果:

如何做到这一点...

多元素选择

它是如何工作的...

在这个例子中,你可能会首先注意到 D3 选择 API 的使用与单元素版本是多么相似。这是 D3 选择 API 强大的设计选择之一。无论你针对多少个元素,无论是单个还是多个,修饰函数都是一样的。我们之前章节中提到的所有修饰函数都可以直接应用于多元素选择,换句话说,D3 选择是基于集合的。

现在既然已经说了这些,让我们更仔细地看看本节中展示的代码示例,尽管它通常很简单且具有自我描述性。在第 A 行,使用了d3.selectAll函数来选择页面上的所有div元素。这个函数调用的返回值是一个包含所有三个div元素的 D3 选择对象。紧接着,在第 B 行,对这个选择对象调用了attr函数,将所有三个div元素的class属性设置为red box。正如这个例子所示,选择和操作代码非常通用,如果现在页面上有超过三个div元素,代码也不会改变。这看起来现在似乎是一个微不足道的便利,但在后面的章节中,我们将展示这种便利如何使您的可视化代码更简单、更容易维护。

迭代选择

有时能够迭代选择中的每个元素并根据它们的位置不同地修改每个元素是非常方便的。在这个菜谱中,我们将向您展示如何使用 D3 选择迭代 API 实现这一点。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter2/selection-iteration.html

如何操作...

D3 选择对象提供了一个简单的迭代接口,以类似于迭代 JavaScript 数组的方式执行迭代。在这个例子中,我们将迭代我们在前一个菜谱中使用的三个选中的div元素,并用索引号标注它们。

<div></div>
<div></div>
<div></div>

<script type="text/javascript">
d3.selectAll("div") // <-- A
 .attr("class", "red box") // <-- B
 .each(function (d, i) { // <-- C
 d3.select(this).append("h1").text(i); // <-- D
 });
</script>

小贴士

选择本质上是一种数组,尽管有一些增强。我们将在后面的章节中探索原始选择及其数组形式,以及如何处理它。

前面的代码片段产生了以下视觉效果:

如何操作...

选择迭代

它是如何工作的...

这个例子是在我们之前章节中看到的内容的基础上构建的。除了在第 A 行选择页面上的所有div元素并在第 B 行设置它们的类属性之外,在这个例子中我们还对选择调用了each函数,以展示您如何迭代一个多元素选择并分别处理每个元素。

注意

这种在另一个函数的返回值上调用函数的形式被称为函数链式调用。如果您不熟悉这种调用模式,请参阅第一章 使用 D3.js 入门,其中解释了该主题。

选择器.each(function) 函数each 函数接受一个迭代器函数作为其参数。给定的迭代器函数可以接收两个可选参数 di,以及一个作为 this 引用传递的隐藏参数,该引用指向当前 DOM 元素对象。第一个参数 d 代表绑定到该特定元素的数值(如果您觉得这很困惑,不要担心,我们将在下一章深入讲解数据绑定)。第二个参数 i 是正在迭代的当前元素对象的索引号。这个索引是从零开始的,意味着它从零开始,每次遇到新元素时增加。

选择器.append(name) 函数:在本例中引入的另一个新函数是 append 函数。该函数创建一个具有给定名称的新元素,并将其追加到当前选择中每个元素的最后一个子元素。它返回一个包含新追加元素的新选择。现在,有了这些知识,让我们更仔细地看看本例中的代码示例。

d3.selectAll("div") // <-- A
    .attr("class", "red box") // <-- B
    .each(function (d, i) { // <-- C
        d3.select(this).append("h1").text(i); // <-- D
    });

迭代器函数定义在第 C 行,包含 di 参数。第 D 行稍微有趣一些。在第 D 行的开始处,this 引用被 d3.select 函数包裹。这种包裹实际上产生了一个包含当前 DOM 元素的单个元素选择。一旦被包裹,就可以在 d3.select(this) 上使用标准的 D3 选择操作 API。之后,在当前元素选择上调用 append("h1") 函数,将新创建的 h1 元素追加到当前元素。然后它简单地设置这个新创建的 h1 元素的文本内容为当前元素的索引号。这产生了如图所示编号框的视觉效果。再次提醒,索引从 0 开始,每次遇到新元素时增加 1。

小贴士

DOM 元素对象本身具有非常丰富的接口。如果您想了解在迭代器函数中它能做什么,请参阅 developer.mozilla.org/en-US/docs/DOM/element 的 DOM 元素 API。

执行子选择

在处理可视化时,执行范围选择是很常见的。例如,选择特定 section 元素内的所有 div 元素是这种范围选择的一个用例。在本例中,我们将展示如何通过不同的方法和它们的优缺点来实现这一点。

准备工作

在您的网页浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter2/sub-selection.html

如何实现...

以下代码示例使用 D3 支持的两种不同的子选择样式选择了两个不同的 div 元素。

<section id="section1">
    <div>
        <p>blue box</p>
    </div>
</section>
<section id="section2">
    <div>
        <p>red box</p>
    </div>
</section>

<script type="text/javascript">
 d3.select("#section1 > div") // <-- A
            .attr("class", "blue box");

 d3.select("#section2") // <-- B
 .select("div") // <-- C
            .attr("class", "red box"); 
</script>

这段代码生成了以下视觉输出:

如何做到这一点...

子选择

它是如何工作的...

虽然产生相同的效果,但这个例子展示了两种非常不同的子选择技术。我们将分别在这里讨论它们,以便您了解它们的优缺点以及何时使用一种而不是另一种。

选择器第三级组合器:在行 A 中,d3.select 使用了一个看起来特殊的字符串,该字符串由一个标签名通过大于号(U+003E,>)连接到另一个标签名。这种语法被称为组合器(这里的大于号表示它是一个子组合器)。第三级选择器支持几种不同的结构组合器。在这里,我们将快速介绍其中最常见的一些。

后代组合器:这个组合器的语法类似于selector selector

如其名所示,后代组合器用于描述两个选择器之间松散的父子关系。之所以称为松散的父子关系,是因为后代组合器不关心第二个选择器是否是父选择器的子、孙子或曾孙。让我们通过一些例子来说明这种松散关系概念。

<div>
<span>
The quick <em>red</em> fox jumps over the lazy brown dog
   </span>
</div>

使用以下选择器:

div em

它将选择em元素,因为divem元素的祖先,而emdiv元素的子代。

子组合器:这个组合器的语法类似于selector > selector

子组合器提供了一种更严格的方式来描述两个元素之间的父子关系。子组合器是通过使用大于号(U+003E,>)字符分隔两个选择器来定义的。

以下选择器:

span > em

它将选择em元素,因为在我们例子中emspan元素的直接子代。而选择器div > em将不会产生任何有效选择,因为em不是div元素的直接子代。

注意

第三级选择器也支持兄弟组合器,但由于它不太常见,我们在这里不进行介绍;感兴趣的读者可以参考 W3C 第三级选择器文档www.w3.org/TR/css3-selectors/#sibling-combinators

W3C 第四级选择器提供了一些有趣的附加组合器,即跟随兄弟和引用组合器,这些组合器可以提供一些非常强大的目标选择能力;更多详情请参阅dev.w3.org/csswg/selectors4/#combinators

D3 子选择:在行 B 和 C 上,使用了不同类型的子选择技术。在这种情况下,首先在行 B 上对section #section2元素进行了简单的 D3 选择。紧接着,另一个select被链式调用,以选择行 C 上的div元素。这种链式选择定义了一个范围选择。用简单的话说,这基本上意味着选择一个嵌套在#section2下的div元素。在语义上,这本质上类似于使用后代组合器#section2 div。然而,这种子选择形式的优势在于,由于父元素是单独选择的,因此它允许你在选择子元素之前处理父元素。为了演示这一点,让我们看一下以下代码片段:

d3.select("#section2") // <-- B
    .style("font-size", "2em") // <-- B-1
    .select("div") // <-- C
    .attr("class", "red box");

如前述代码片段所示,现在在我们选择div元素之前,我们可以在行 B-1 上对#section2应用一个修改器函数。这种灵活性将在下一节进一步探讨。

函数链

如我们所见,D3 API 完全围绕函数链的概念设计。因此,它几乎形成了一个用于动态构建 HTML/SVG 元素的领域特定语言(DSL)。在这个代码示例中,我们将看看如何仅使用 D3 构建上一个示例的整个主体结构。

注意

如果领域特定语言(DSL)对你来说是一个新概念,我强烈推荐查看 Martin Fowler 在其书籍《领域特定语言》(Domain-Specific Languages)中的精彩解释摘录。摘录可以在www.informit.com/articles/article.aspx?p=1592379找到。

准备工作

在你的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter2/function-chain.html

如何操作...

让我们看看如何使用函数链来生成简洁且易于阅读的代码,从而产生动态视觉内容。

<script type="text/javascript">
 var body = d3.select("body"); // <-- A

 body.append("section") // <-- B
 .attr("id", "section1") // <-- C
 .append("div") // <-- D
 .attr("class", "blue box") // <-- E
 .append("p") // <-- F
 .text("dynamic blue box"); // <-- G

  body.append("section")
      .attr("id", "section2")
    .append("div")
      .attr("class", "red box")
    .append("p")
      .text("dynamic red box");
</script>

此代码生成以下视觉输出(与我们之前章节中看到的内容相似):

如何操作...

函数链

工作原理...

尽管与上一个示例在视觉上相似,但在这个示例中 DOM 元素的构建过程与上一个示例有显著不同。正如代码示例所示,与上一个食谱中存在的sectiondiv元素不同,页面上没有静态 HTML 元素。

让我们仔细看看这些元素是如何动态创建的。在第 A 行,对顶级 body 元素进行了通用选择。使用名为 body 的局部变量缓存了 body 选择结果。然后在第 B 行,将一个新的 section 元素附加到 body 上。记住,append 函数返回一个包含新附加元素的新选择,因此在第 C 行,可以将新创建的 section 元素的 id 属性设置为 section1。之后在第 D 行,创建了一个新的 div 元素并将其附加到 #section1 上,其 CSS 类在第 E 行设置为 blue box。下一步,同样在第 F 行,将一个 paragraph 元素附加到 div 元素上,其文本内容在第 G 行设置为 dynamic blue box

如此示例所示,此链式过程可以继续创建任意复杂度的结构。实际上,这就是通常基于 D3 的数据可视化结构是如何创建的。许多可视化项目仅包含一个 HTML 骨架,而依赖于 D3 创建其余部分。如果你想要高效地使用 D3 库,熟悉这种函数链式方法至关重要。

小贴士

一些 D3 的修改函数返回一个新的选择,例如 selectappendinsert 函数。使用不同级别的缩进区分函数链应用于哪个选择是一个好习惯。

操作原始选择

有时,尽管不经常,访问 D3 原始选择数组在开发中可能有益,无论是用于调试目的还是与其他需要访问原始 DOM 元素的 JavaScript 库集成;在这个配方中,我们将向您展示如何做到这一点。我们还将看到 D3 选择对象的一些内部结构。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter2/raw-selection.html

如何操作...

当然,您可以通过使用 nth-child 选择器或通过 each 的选择器迭代函数来实现这一点,但在某些情况下,这些选项可能过于繁琐和不方便。这就是您可能会发现处理原始选择数组作为更方便的方法的时候。在这个例子中,我们将看到如何访问和利用原始选择数组。

<table class="table">
    <thead>
    <tr>
        <th>Time</th>
        <th>Type</th>
        <th>Amount</th>
    </tr>
    </thead>
    <tbody>
    <tr>
        <td>10:22</td>
        <td>Purchase</td>
        <td>$10.00</td>
    </tr>
    <tr>
        <td>12:12</td>
        <td>Purchase</td>
        <td>$12.50</td>
    </tr>
    <tr>
        <td>14:11</td>
        <td>Expense</td>
        <td>$9.70</td>
    </tr>
    </tbody>
</table>

<script type="text/javascript">
 var rows = d3.selectAll("tr");// <-- A

 var headerElement = rows[0][0];// <-- B

 d3.select(headerElement).attr("class","table-header");// <--C

 d3.select(rows[0][1]).attr("class","table-row-odd"); //<-- D
 d3.select(rows[0][2]).attr("class","table-row-even"); //<-- E
 d3.select(rows[0][3]).attr("class","table-row-odd"); //<-- F
</script>

此配方生成以下视觉输出:

如何操作...

原始选择操作

工作原理...

在这个例子中,我们通过现有的 HTML 表格来着色表格。这并不是一个很好的例子,说明您如何使用 D3 着色表格的奇数和偶数行。相反,这个例子旨在展示如何访问原始选择数组。

小贴士

在表格中为奇数和偶数行着色的一个更好的方法是使用 each 函数,然后依赖于索引参数 i 来完成这项工作。

在行 A 中,我们选择了所有行并将选择存储在 rows 变量中。D3 选择存储在一个二维 JavaScript 数组中。选中的元素存储在一个数组中,然后被包裹在一个单元素数组中。因此,为了访问第一个选中的元素,你需要使用 rows[0][0],而第二个元素可以通过 rows[0][1] 访问。正如我们在行 B 中看到的,表头元素可以通过 rows[0][0] 访问,这将返回一个 DOM 元素对象。同样,正如我们在前面的章节中演示的那样,任何 DOM 元素都可以通过 d3.select 直接选择,如行 C 所示。行 D、E 和 F 展示了如何直接索引和访问选择中的每个元素。

在某些情况下,原始选择访问可能很有用;然而,由于它依赖于直接访问 D3 选择数组,它会在你的代码中创建一个结构依赖。换句话说,如果在 D3 的未来版本中这个结构发生了变化,那么依赖于它的代码将会被破坏。因此,除非绝对必要,否则建议避免使用原始选择操作。

小贴士

这种方法通常不是必要的,但在某些情况下可能会很有用,例如在你的单元测试用例中,当你需要快速知道每个元素的绝对索引并获得它们的引用时。我们将在后面的章节中更详细地介绍单元测试。

第三章. 处理数据

在本章中,我们将涵盖:

  • 将数组作为数据绑定

  • 将对象字面量作为数据绑定

  • 将函数作为数据绑定

  • 与数组一起工作

  • 使用数据过滤

  • 使用数据排序

  • 从服务器加载数据

简介

在本章中,我们将探讨任何数据可视化项目中最重要的基本问题,即数据如何在编程结构和其视觉隐喻中得以表示。在我们开始这个话题之前,对数据可视化是什么的讨论是必要的。为了理解数据可视化是什么,首先我们需要了解数据和信息之间的区别。

数据是原始事实。这个词“原始”意味着这些事实尚未经过处理以揭示其含义...信息是处理原始数据以揭示其含义的结果。

(Rob P., S. Morris, and Coronel C. 2009)

这是在数字信息世界中传统上定义数据和信息的途径。然而,数据可视化提供了对这个定义的更丰富解释,因为信息不再是经过处理的原始事实的简单结果,而是一种事实的视觉隐喻。正如 Manuel Lima 在他的《信息可视化宣言》中所建议的,在物质世界中,形式被视为功能的追随者。

同一个数据集可以生成任意数量的可视化,它们在有效性方面可能具有同等的要求。从某种意义上说,可视化更多的是关于传达创作者对数据的洞察,而不是其他任何事情。更进一步地说,Card、McKinlay 和 Shneiderman 提出,信息可视化的实践可以描述为:

使用计算机支持的、交互的、抽象数据的视觉表示来增强认知。

(Card S. & Mackinly J. and Shneiderman B. 1999)

在接下来的章节中,我们将探讨 D3 提供的各种技术,以在数据与视觉领域之间架起桥梁。这是我们能够用数据创建认知放大器之前需要采取的第一个步骤。

进入-更新-退出模式

将每个数据点与其视觉表示相匹配的任务,例如,为数据集中的每个数据点绘制一个条形图,当数据点发生变化时更新条形图,然后最终在某个数据点不再存在时删除条形图,这似乎是一个复杂且繁琐的任务。这正是 D3 被设计用来提供一种巧妙的方法来简化这种连接的实现。这种定义数据与其视觉表示之间连接的方式通常被称为 D3 中的 进入-更新-退出 模式。这种模式与大多数开发者熟悉的典型 命令式方法 有着根本的不同。然而,理解这个模式对于你在 D3 库中的有效性至关重要,因此,在本节中,我们将重点解释这个模式背后的概念。首先,让我们看一下以下两个域的概念性插图:

The enter-update-exit pattern

数据和视觉集

在之前的示例中,两个圆圈代表两个连接的集合。集合 A 描述了你的数据集,而集合 B 代表视觉元素。这正是 D3 看待数据与视觉元素之间联系的方式。你可能想知道基本的集合理论如何帮助你在这里的数据可视化工作中。让我来解释。

首先,让我们考虑这样一个问题,我如何找到所有当前代表其对应数据点的视觉元素?答案是 A∩B;这表示集合 A 和 B 的交集,存在于数据视觉域中的元素。

The enter-update-exit pattern

更新模式

阴影区域表示两个集合——A 和 B 之间的交集。在 D3 中,可以使用 selection.data 函数来选择这个交集——A∩B。

在选择上,selection.data(data) 函数设置了数据域和视觉域之间的连接,正如我们上面讨论的那样。初始选择形成了视觉集 B,而 data 函数中提供的数据形成了数据集 A。这个函数的返回结果是所有存在于这个交集中的新选择(数据绑定选择)。现在,你可以对这个新选择调用修改函数来更新所有现有元素。这种选择模式通常被称为 更新 模式。

我们在这里需要回答的第二个问题是 我如何定位尚未可视化的数据。答案是 A 和 B 的集合差,表示为 A\B,或者从视觉上看,以下插图:

The enter-update-exit pattern

进入模式

集合 A 中的阴影区域表示尚未可视化的数据点。为了访问这个 A\B 子集,需要在数据绑定的 D3 选择(由 data 函数返回的选择)上执行以下函数。

selection.data(data).enter() 函数返回一个新的选择,表示 A\B 子集,其中包含所有尚未在视觉域中表示的数据。然后,常规的修改函数可以链接到这个新的选择方法,以创建表示给定数据元素的新的视觉元素。这种选择模式简单地被称为 Enter 模式。

我们讨论的最后一个案例涵盖了存在于我们的数据集中但不再与任何对应数据元素相关联的视觉元素。你可能会问这种类型的视觉元素最初是如何存在的。这通常是由于从数据集中删除元素造成的。如果你最初在数据集中可视化了所有数据元素,然后删除了一些数据元素。现在,你有一些视觉元素不再代表数据集中的任何有效数据点。这个子集可以通过使用 Update 差分的逆运算来发现,表示为 B\A

enter-update-exit 模式

Exit 模式

上一幅图中的阴影区域表示我们在这里讨论的差异。可以使用 selection.exit 函数在数据绑定选择上选择这个子集。

当在数据绑定的 D3 选择上调用 selection.data(data).exit() 函数时,它会计算一个新的选择,其中包含所有不再与任何有效数据元素相关联的视觉元素。作为一个有效的 D3 选择对象,修改函数可以链接到这个选择,以更新和删除不再需要的这些视觉元素。这种选择模式被称为 Exit 模式。

三个不同的选择模式共同涵盖了数据与视觉域之间所有可能的交互情况。enter-update-exit 模式是任何 D3 驱动的可视化的基石。在接下来的章节中,我们将介绍如何有效地利用这些选择方法来生成数据驱动的视觉元素。

将数组绑定为数据

在 D3 可视化中定义数据最常见和最受欢迎的方法是通过使用 JavaScript 数组。例如,假设你有一个数组中存储了多个数据元素,并且你想要生成相应的视觉元素来表示每一个。此外,当数据数组更新时,你希望你的可视化能够立即反映这些变化。在这个菜谱中,我们将完成这个常见的做法。

准备工作

在你的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter3/array-as-data.html

如何做到这一点...

可能首先想到的第一个和最自然的解决方案是遍历数据数组元素,并在页面上生成它们对应的视觉元素。这绝对是一个有效的解决方案,并且它将使用 D3 工作得很好,然而,我们在介绍中讨论的 enter-update-exit 模式提供了一个更简单、更有效的方法来生成视觉元素。让我们看看我们是如何做到这一点的:

var data = [10, 15, 30, 50, 80, 65, 55, 30, 20, 10, 8]; // <- A

function render(data) { // <- B
        // Enter
 d3.select("body").selectAll("div.h-bar") // <- C
 .data(data) // <- D
 .enter() // <- E
 .append("div") // <- F
 .attr("class", "h-bar")
 .append("span"); // <- G

        // Update
 d3.select("body").selectAll("div.h-bar")
 .data(data) 
 .style("width", function (d) { // <- H
 return (d * 3) + "px";
 })
 .select("span") // <- I
 .text(function (d) {
 return d;
 });

        // Exit
 d3.select("body").selectAll("div.h-bar")
 .data(data)
 .exit() // <- J
               .remove();        
 }

 setInterval(function () { // <- K
        data.shift();
        data.push(Math.round(Math.random() * 100));

        render(data);
 }, 1500);

 render(data);

此菜谱生成以下视觉输出:

如何操作...

数据作为数组

它是如何工作的...

在此示例中,数据(在这种情况下是一个整数列表)存储在简单的 JavaScript 数组中,如标记为 A 的行所示,其左侧有一个箭头。render 函数定义在标记为 B 的行上,以便它可以被重复调用以更新我们的可视化。Enter 选择实现从标记为 C 的行开始,选择网页上所有具有 h-bar CSS 类的 div 元素。你可能想知道为什么我们要选择这些 div 元素,因为它们甚至还没有在网页上存在。这实际上是正确的;然而,在这个阶段的这个选择是用来定义我们在介绍中讨论的视觉集。通过发出我们在上一行所做的这个选择,我们实际上是在声明应该在网页上有一组 div.h-bar 元素来形成我们的视觉集。在标记为 D 的行上,我们调用这个初始选择上的 data 函数,将数组作为数据集绑定到即将创建的视觉元素。一旦定义了这两个集合,就可以使用 enter() 函数来选择所有尚未可视化的数据元素。当 render 函数第一次被调用时,它返回数据数组中的所有元素,如下面的代码片段所示:

        d3.select("body").selectAll("div.h-bar") // <- C
             .data(data) // <- D
            .enter() // <- E
             .append("div") // <- F
               .attr("class", "h-bar")
              .append("span"); // <- G

在行 F 上,创建了一个新的 div 元素,并将其附加到 enter 函数中选中的每个数据元素的 body 元素上;这实际上为每个数据元素创建了一个 div 元素。最后,在行 G 上,创建了一个名为 span 的元素并将其附加到 div 元素上,我们将其 CSS 类设置为 h-bar。到此为止,我们基本上已经创建了可视化结构的骨架,包括空的 divspan 元素。下一步是根据给定的数据更改元素的视觉属性。

小贴士

D3 向 DOM 元素注入一个名为 __data__ 的属性,以便使数据与视觉元素粘合,因此当使用修改后的数据集进行选择时,D3 可以正确地计算差异和交集。如果您检查 DOM 元素,无论是通过调试器进行视觉检查还是通过编程方式,都可以轻松地看到这个属性。

如何工作...

如前一个屏幕截图所示,当你调试可视化实现时,了解这一点非常有用。

array-as-data.htmlUpdate部分,前两行与我们之前在Enter部分所做的是相同的,这本质上定义了我们的数据集和视觉集。这里的主要区别在于行H。在Update模式下,我们直接将修饰函数应用到data函数所做的选择上,而不是像在前面段落中提到的Enter代码那样调用enter函数。在Update模式下,data函数返回数据集和视觉集的交集(A∩B)。在行H上,我们应用了一个动态样式属性width,其值是以下代码片段中显示的每个视觉元素的整数值的 3 倍:

        d3.select("body").selectAll("div.h-bar")
            .data(data) 
                .style("width", function (d) { // <- H
                    return (d * 3) + "px";
                })
                .select("span") // <- I
                    .text(function (d) {
                        return d;
                    });

所有 D3 修饰函数都接受这种类型的动态函数来实时计算其值。这正是“数据驱动”你的可视化的含义。因此,理解这个函数在我们例子中的设计目的是至关重要的。这个函数接收一个参数d,它是与当前元素关联的数据。在我们的例子中,第一个div条形图具有与其数据关联的值10,而第二个条形图有15,依此类推。因此,这个函数本质上计算了一个数值,它是每个条形图数据的 3 倍,并将其作为像素值返回width

值得在此一提的另一个有趣点是关于行I,我们提到了span属性。子span元素也可以使用动态修饰函数,并且可以访问从其父元素传播来的相同数据。这是 D3 数据绑定的默认行为。任何附加到数据绑定元素的元素都会自动继承父元素的数据。

注意

动态修饰函数实际上接受两个参数di。第一个参数d是我们在这里讨论的关联数据,而i是当前元素的零基于索引号。前一章的一些食谱依赖于这个索引,在本章的其余部分,我们将看到其他利用这个索引以不同方式的应用食谱。

这是更新过程产生的原始 HTML 代码:

<div class="h-bar" style="width: 30px;">
<span>10</span>
</div>
<div class="h-bar" style="width: 45px;">
<span>15</span>
</div>
....
<div class="h-bar" style="width: 24px;">
<span>8</span>
   </div>

小贴士

enter模式下创建并附加的元素,即行FG,会自动添加到update集中。因此,不需要在代码的enterupdate部分重复视觉属性修改逻辑。

最后的部分——Exit部分——如所示相当简单:

        d3.select("body").selectAll("div.h-bar")
            .data(data)
            .exit() // <- J
            .remove();

小贴士

exit()函数返回的选择与任何其他选择一样。因此,尽管remove是对exit选择最常用的操作,但你也可以将其他修饰符或过渡应用到这个选择上。我们将在后面的章节中探讨一些这些选项。

在行 J 上,调用 exit() 函数来计算所有不再与任何数据关联的可视元素集合的差异。最后,在这个选择集上调用 remove() 函数来移除由 exit() 函数选中的所有元素。这样,只要我们在更改数据后调用 render() 函数,我们总能确保我们的视觉表示和数据保持同步。

现在,最后的代码块如下:

setInterval(function () { // <- K
        data.shift();
        data.push(Math.round(Math.random() * 100));
        render(data);
 }, 1500);

在行 K 上,创建了一个简单的函数 function(),使用 shift 函数移除数据数组中的顶部元素,同时使用 push() 函数每 1.5 秒向数据数组中添加一个随机整数。一旦数据数组更新,就再次调用 render() 函数来更新我们的可视化,使其与新数据集保持同步。这就是我们的示例具有动画条形图外观的原因。

将对象字面量绑定为数据

在更复杂的可视化中,数据数组中的每个元素可能不是原始整数值或字符串,而是一个 JavaScript 对象本身。在这个配方中,我们将讨论如何利用这种更复杂的数据结构来驱动使用 D3 的可视化。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter3/object-as-data.html

如何实现...

JavaScript 对象字面量可能是您在 Web 上加载数据源时遇到的最常见的数据结构。在这个配方中,我们将探讨如何利用这些 JavaScript 对象来生成丰富的可视化。以下是代码实现方式:

var data = [ // <- A
 {width: 10, color: 23},{width: 15, color: 33},
 {width: 30, color: 40},{width: 50, color: 60},
 {width: 80, color: 22},{width: 65, color: 10},
 {width: 55, color: 5},{width: 30, color: 30},
 {width: 20, color: 60},{width: 10, color: 90},
 {width: 8, color: 10}
 ];

var colorScale = d3.scale.linear()
.domain([0, 100]).range(["#add8e6", "blue"]); // <- B

    function render(data) {
        d3.select("body").selectAll("div.h-bar")
            .data(data)
            .enter().append("div")
                .attr("class", "h-bar") 
            .append("span");

        d3.select("body").selectAll("div.h-bar")
            .data(data)
            .exit().remove();

        d3.select("body").selectAll("div.h-bar")
            .data(data)
                .attr("class", "h-bar")
 .style("width", function (d) { // <- C
 return (d.width * 5) + "px"; // <- D
 })
 .style("background-color", function(d){
 return colorScale(d.color); // <- E
 })
            .select("span")
 .text(function (d) {
 return d.width; // <- F
 });
    }

    function randomValue() {
        return Math.round(Math.random() * 100);
    }

    setInterval(function () {
        data.shift();
        data.push({width: randomValue(), color: randomValue()});
        render(data);
    }, 1500);

    render(data);

这个配方生成了以下可视化:

如何实现...

数据作为对象

工作原理...

在这个配方中,与上一个配方中的简单整数不同,现在我们的数据数组填充了对象(见带有箭头指向的标记为 A 的行)。每个数据对象包含两个属性——widthcolor——在这个例子中这两个属性都是整数。

备注

这个配方建立在之前的配方之上,所以如果您不熟悉基本的 enter-update-exit 选择模式,请首先查看之前的配方。

var data = [ // <- A

        {width: 10, color: 23},{width: 15, color: 33},
...
        {width: 8, color: 10}
    ];

备注

在行 B 上,定义了一个看起来很复杂的颜色比例。包括颜色比例在内的比例将在下一章中深入讨论,所以现在我们假设这是一个我们可以使用它来生成 CSS 兼容颜色代码的函数,给定一些整数输入值。这对于本配方来说是足够的。

与上一个配方相比,这个配方的主要区别在于如何处理数据,如行 C 所示:

function (d) { // <- C
return (d.width * 5) + "px"; // <- D
}

如前述代码片段所示,在这个配方中,与每个可视元素关联的 datum 实际上是一个对象,而不是整数。因此,我们可以在行 D 上访问 d.width 属性。

小贴士

如果您的对象有自己的函数,您也可以在这里通过动态修改函数访问它们。这是在数据源中添加一些数据特定辅助函数的一种方便方式。然而,请注意,由于动态函数通常在可视化过程中被多次调用,因此您所依赖的函数应该尽可能高效地实现。如果这不可能,那么在将它们绑定到可视化过程之前预处理您的数据是最好的选择。

类似地,在行 E 上,可以使用 d.color 属性以及我们之前定义的颜色尺度来计算 background-color 样式:

.style("background-color", function(d){
  return colorScale(d.color); // <- E
})
.select("span")
  .text(function (d) {
    return d.width; // <- F
  });

子元素 span 再次继承了其父元素关联的数据,因此它在其行 F 上的动态修改函数中也有权访问相同的数据对象,设置文本内容为 d.width 属性。

这个菜谱展示了如何使用与之前菜谱中讨论的完全相同的方法轻松地将 JavaScript 对象绑定到视觉元素上。这是 D3 库最强大的功能之一;它允许您使用相同的模式和方式处理不同类型的数据,无论是简单还是复杂。我们将在下一个菜谱中看到更多关于这个主题的例子。

将函数作为数据绑定

D3 对函数式 JavaScript 编程的优秀支持带来的一个好处是,它允许将函数本身作为数据来处理。在特定情况下,这个特性可以提供一些非常强大的功能。这是一个更高级的菜谱。如果您是 D3 的新手,并且一开始对它有些难以理解,请不要担心。随着时间的推移,这种用法将变得自然。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter3/function-as-data.html

如何实现...

在这个菜谱中,我们将探讨将函数本身作为数据绑定到您的视觉元素上的可能性。如果正确使用,这种能力非常强大且灵活:

<div id="container"></div>

<script type="text/javascript">
    var data = []; // <- A

 var next = function (x) { // <- B
 return 15 + x * x;
 };

 var newData = function () { // <- C 
 data.push(next);
 return data;
 };

   function render(){
        var selection = d3.select("#container")
                  .selectAll("div")
 .data(newData); // <- D

        selection.enter().append("div").append("span");

        selection.exit().remove();

        selection.attr("class", "v-bar")
 .style("height", function (d, i) {
 return d(i)+"px"; // <- E
 })
        .select("span")
 .text(function(d, i){ 
 return d(i); } // <- F
 ); 
   }

   setInterval(function () {
       render();
   }, 1500);

   render();
</script>

上述代码产生了以下条形图:

如何实现...

数据作为函数

它是如何工作的...

在这个菜谱中,我们选择使用一系列垂直条来可视化公式 15 + x * x 的输出,每个条目都附有它所代表的积分值。这种可视化每 1.5 秒在上一条右侧添加一个新的条目。当然,我们可以使用之前两个菜谱中讨论的技术来实现这种可视化。因此,我们使用公式生成一个整数数组,然后在重新渲染可视化之前,每 1.5 秒从 n 追加一个新的整数到 n+1。然而,在这个菜谱中,我们决定采取一种更函数式的方法。

这次我们在行A上从一个空的数据数组开始。在行B,定义了一个简单的函数来计算公式15+x²的结果。然后在行C,创建了一个函数来生成当前数据集,该数据集包含对next函数的n+1个引用。以下是功能数据定义的代码:

    var data = []; // <- A

    var next = function (x) { // <- B
        return 15 + x * x;
    };

   var newData = function () { // <- C        
        data.push(next);
        return data;
    };

这种设置似乎是为了达到我们的可视化目标而显得有些奇怪。让我们看看我们如何在可视化代码中利用所有这些函数。在行D,我们将数据绑定到div元素的选择上,就像我们在之前的食谱中所做的那样。然而,这次数据不是一个数组,而是newData函数:

        var selection = d3.select("#container")
                   .selectAll("div")
                   .data(newData); // <- D

当涉及到数据时,D3 非常灵活。如果你向data函数提供一个函数,D3 将简单地调用该函数,并使用该函数返回的值作为data函数的参数。在这种情况下,newData函数返回的数据是一个函数引用数组。因此,现在在我们的动态修改函数中,在行EF,传递给这些函数的数据d实际上是next函数的引用,如下面的代码所示:

        selection.attr("class", "v-bar")
            .style("height", function (d, i) {
                return d(i)+"px"; // <- E
            })
            .select("span")
                .text(function(d, i){ 
                    return d(i); } // <- F
                ); 

作为对函数的引用,现在可以使用索引i作为参数调用d,这反过来又生成了我们可视化所需的公式输出。

注意

在 JavaScript 中,函数是特殊的对象,因此从语义上讲,这与绑定对象作为数据完全相同。关于这个话题的另一个注意事项是,数据也可以被认为是函数。例如,整数这样的常量值可以被视为一个静态函数,该函数简单地返回它接收的内容,而不进行任何修改。

这种技术可能不是可视化中最常用的技术,但使用得当,它非常灵活且强大,尤其是在你有流动数据集时。

注意

数据函数通常需要是幂等的才有意义。幂等性是指能够多次应用相同的函数和相同的输入,而不会改变结果超过初始应用。有关幂等性的更多详细信息,请访问:en.wikipedia.org/wiki/Idempotence

与数组一起工作

我们的大部分数据都存储在数组中,我们花费了大量精力与数组一起工作,以格式化和重构数据。这就是为什么 D3 提供了一套丰富的面向数组的实用函数,使得这项任务变得容易得多。在本食谱中,我们将探讨一些在这个方面最常见和最有帮助的实用函数。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter3/working-with-array.html

如何做到这一点...

以下代码示例展示了 D3 库提供的某些最常见和最有帮助的数组实用函数及其效果:

<script type="text/javascript">
 // Static html code were omitted due to space constraint

    var array = [3, 2, 11, 7, 6, 4, 10, 8, 15];

    d3.select("#min").text(d3.min(array));
    d3.select("#max").text(d3.max(array));
    d3.select("#extent").text(d3.extent(array));
    d3.select("#sum").text(d3.sum(array));
    d3.select("#median").text(d3.median(array));
    d3.select("#mean").text(d3.mean(array));
    d3.select("#asc").text(array.sort(d3.ascending));
d3.select("#desc").text(array.sort(d3.descending));      d3.select("#quantile").text(
d3.quantile(array.sort(d3.ascending), 0.25)
);
d3.select("#bisect").text(
d3.bisect(array.sort(d3.ascending), 6)
    );

    var records = [
        {quantity: 2, total: 190, tip: 100, type: "tab"},
        {quantity: 2, total: 190, tip: 100, type: "tab"},
        {quantity: 1, total: 300, tip: 200, type: "visa"},
        {quantity: 2, total: 90, tip: 0, type: "tab"},
        {quantity: 2, total: 90, tip: 0, type: "tab"},
        {quantity: 2, total: 90, tip: 0, type: "tab"},
        {quantity: 1, total: 100, tip: 0, type: "cash"},
        {quantity: 2, total: 90, tip: 0, type: "tab"},
        {quantity: 2, total: 90, tip: 0, type: "tab"},
        {quantity: 2, total: 90, tip: 0, type: "tab"},
        {quantity: 2, total: 200, tip: 0, type: "cash"},
        {quantity: 1, total: 200, tip: 100, type: "visa"}
    ];

    var nest = d3.nest()
            .key(function (d) { // <- A
                return d.type;
            })
            .key(function (d) { // <- B
                return d.tip;
            })
            .entries(records); // <- C

    d3.select("#nest").html(printNest(nest, ""));

    function printNest(nest, out, i) {
        if(i === undefined) i = 0;

        var tab = ""; 
        for(var j = 0; j < i; ++j) 
            tab += " ";

        nest.forEach(function (e) {
            if (e.key)
                out += tab + e.key + "<br>";
            else
                out += tab + printObject(e) + "<br>";

            if (e.values)
                out = printNest(e.values, out, ++i);
            else
                return out;
        });

        return out;
    }

    function printObject(obj) {
        var s = "{";
        for (var f in obj) {
            s += f + ": " + obj[f] + ", ";
        }
        s += "}";
        return s;
    }
</script> 

前面的代码产生以下输出:

d3.min => 2
d3.max => 15
d3.extent => 2,15
d3.sum => 66
d3.median => 7
d3.mean => 7.333333333333333
array.sort(d3.ascending) => 2,3,4,6,7,8,10,11,15
array.sort(d3.descending) => 15,11,10,8,7,6,4,3,2
d3.quantile(array.sort(d3.ascending), 0.25) => 4
d3.bisect(array.sort(d3.ascending), 6) => 4

tab100{quantity: 2, total: 190, tip: 100, type: tab, }{quantity: 2, total: 190, tip: 100, type: tab, }0{quantity: 2, total: 90, tip: 0, type: tab, }{quantity: 2, total: 90, tip: 0, type: tab, }{quantity: 2, total: 90, tip: 0, type: tab, }{quantity: 2, total: 90, tip: 0, type: tab, }{quantity: 2, total: 90, tip: 0, type: tab, }{quantity: 2, total: 90, tip: 0, type: tab, }visa200{quantity: 1, total: 300, tip: 200, type: visa, }100{quantity: 1, total: 200, tip: 100, type: visa, }cash, }0{quantity: 1, total: 100, tip: 0, type: cash, }{quantity: 2, total: 200, tip: 0, type: cash, }

工作原理...

D3 提供了各种实用函数来帮助在 JavaScript 数组上执行操作。大多数都很直观且简单,然而,也有一些是固有的。我们将在此部分简要讨论它们。

给定我们的数组为 [3, 2, 11, 7, 6, 4, 10, 8, 15]:

  • d3.min: 此函数检索最小元素,即 2

  • d3.max: 此函数检索最大元素,即 15

  • d3.extent: 此函数检索最小和最大元素,即 [2, 15]

  • d3.sum: 此函数检索数组中所有元素的总和,即 66

  • d3.medium: 此函数找到中值,即7

  • d3.mean: 此函数计算平均值,即 7.33

  • d3.ascending / d3.descending: d3 对象提供了一个内置的比较函数,您可以使用它来对 JavaScript 数组进行排序

    d3.ascending = function(a, b) {  return a < b ? -1 : a > b ? 1 : 0; }
    d3.descending = function(a, b) {  return b < a ? -1 : b > a ? 1 : 0; }
    
  • d3.quantile: 此函数在已排序数组中按升序计算分位数,即 0.25 的分位数是 4

  • d3.bisect: 此函数找到一个插入点,该点位于已排序数组中任何现有元素之后(右侧),即 bisect (array, 6) 产生 4

  • d3.nest: D3 的 nest 函数可以用来构建将平铺数组数据结构转换为层次嵌套结构的算法,特别适合某些类型的可视化。D3 的 nest 函数可以通过将 key 函数链接到 nest 来配置,如行 AB 所示:

        var nest = d3.nest()
                .key(function (d) { // <- A
                    return d.type;
                })
                .key(function (d) { // <- B
                    return d.tip;
                })
                .entries(records); // <- C
    

    可以提供多个 key 函数来生成多个嵌套级别。在我们的例子中,嵌套由两个级别组成,首先是 type 数量,然后是 tip 数量,如下面的输出所示:

    tab
     100
      {quantity: 2, total: 190, tip: 100, type: tab, }
      {quantity: 2, total: 190, tip: 100, type: tab, }
    

    最后,使用 entries() 函数提供如行 C 所示的基于平铺数组的 dataset。

基于数据的过滤

假设你需要根据关联的数据元素过滤 D3 选择,以便根据用户的输入隐藏/显示不同的子数据集。D3 选择提供了一个过滤器函数来执行这种数据驱动的过滤。在本菜谱中,我们将向您展示如何利用这种方式来以数据驱动的方式过滤视觉元素。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter3/data-filter.html

如何操作...

以下示例代码展示了如何利用基于数据的过滤来根据其分类突出显示不同的视觉元素:

<script type="text/javascript">
    var data = [ // <-A
        {expense: 10, category: "Retail"},
        {expense: 15, category: "Gas"},
        {expense: 30, category: "Retail"},
        {expense: 50, category: "Dining"},
        {expense: 80, category: "Gas"},
        {expense: 65, category: "Retail"},
        {expense: 55, category: "Gas"},
        {expense: 30, category: "Dining"},
        {expense: 20, category: "Retail"},
        {expense: 10, category: "Dining"},
        {expense: 8, category: "Gas"}
    ];
    function render(data, category) {
        d3.select("body").selectAll("div.h-bar") // <-B
                .data(data)
            .enter()
            .append("div")
                .attr("class", "h-bar")
            .append("span");

        d3.select("body").selectAll("div.h-bar") // <-C
                .data(data)
            .exit().remove();

        d3.select("body").selectAll("div.h-bar") // <-D
                .data(data)
            .attr("class", "h-bar")
            .style("width", function (d) {
                return (d.expense * 5) + "px";}
            )
            .select("span")
                .text(function (d) {
                    return d.category;
                });

        d3.select("body").selectAll("div.h-bar")
 .filter(function (d, i) { // <-E
 return d.category == category;
 })
                .classed("selected", true);
    }

    render(data);

    function select(category) {
        render(data, category);
    }
</script>

<div class="control-group">
    <button onclick="select('Retail')">
      Retail
    </button>
    <button onclick="select('Gas')">
      Gas
    </button>
    <button onclick="select('Dining')">
      Dining
    </button>
    <button onclick="select()">
      Clear
    </button>
</div>

在点击Dinning按钮后,前面的代码生成以下视觉输出:

如何操作...

基于数据的过滤

工作原理...

在这个菜谱中,我们有一个数据集,它由一系列个人消费记录组成,这些记录以 expensecategory 作为属性,显示在标记为 A 的代码块中。在 BCD 行,使用标准的 enter-update-exit 模式创建了一组水平条(HTML div),以表示消费记录。到目前为止,这个菜谱与 将对象字面量绑定为数据 的菜谱类似。现在让我们看看 E 行:

filter(function (d, i) { // <-E
    return d.category == category;
})

D3 的 selection.filter 函数接受一个函数作为其参数。它将函数应用于现有选择中的每个元素。filter 函数提供的函数有两个参数和一个隐藏的引用:

  • d:它是与当前元素关联的数据

  • i:它是当前元素的零基索引

  • this:这有一个隐藏的引用,指向当前 DOM 元素

D3 的 selection.filter 函数期望提供的函数返回一个布尔值。如果返回的值为真,则相应的元素将被包含在由 filter 函数返回的新选择中。在我们的例子中,filter 函数实际上选择了所有与用户选择的类别匹配的条形,并将 CSS 类 selected 应用到每个条形上。这种方法为你提供了一种强大的方式来过滤和生成数据驱动的子选择,你可以进一步操作或分析以生成专注的视觉化。

提示

D3 的 selection.filter 函数使用 JavaScript 的 真值假值 测试处理返回值,因此并不期望严格的布尔值。这意味着 false、null、0、""、undefined 和 NaN(非数字)都被视为假,而其他东西被认为是真。

基于数据的排序

在许多情况下,根据数据对视觉元素进行排序是可取的,这样你可以从视觉上突出不同元素的重要性。在这个菜谱中,我们将探讨如何在 D3 中实现这一点。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter3/data-sort.html

如何做...

让我们看看如何使用 D3 执行数据驱动的排序和进一步的操作。在这个例子中,我们将根据用户输入对之前菜谱中创建的条形图进行排序,基于支出(宽度)或类别:

<script type="text/javascript">
    var data = [ // <-A
        {expense: 10, category: "Retail"},
        {expense: 15, category: "Gas"},
        {expense: 30, category: "Retail"},
        {expense: 50, category: "Dining"},
        {expense: 80, category: "Gas"},
        {expense: 65, category: "Retail"},
        {expense: 55, category: "Gas"},
        {expense: 30, category: "Dining"},
        {expense: 20, category: "Retail"},
        {expense: 10, category: "Dining"},
        {expense: 8, category: "Gas"}
    ];

    function render(data, comparator) {
        d3.select("body").selectAll("div.h-bar") // <-B
                .data(data)
            .enter().append("div")
                .attr("class", "h-bar")
                .append("span");

        d3.select("body").selectAll("div.h-bar") // <-C
                .data(data)
            .exit().remove();

        d3.select("body").selectAll("div.h-bar") // <-D
                .data(data)
            .attr("class", "h-bar")
            .style("width", function (d) {
                return (d.expense * 5) + "px";
            })
            .select("span")
                .text(function (d) {
                    return d.category;
                });

 if(comparator)
 d3.select("body")
 .selectAll("div.h-bar") 
 .sort(comparator); // <-E
    }

 var compareByExpense = function (a, b) {  // <-F
 return a.expense < b.expense?-1:1;
 };
 var compareByCategory = function (a, b) {  // <-G
 return a.category < b.category?-1:1;
 };

    render(data);

    function sort(comparator) {
        render(data, comparator);
    }
</script>

<div class="control-group">
    <button onclick="sort(compareByExpense)">
        Sort by Width
    </button>
    <button onclick="sort(compareByCategory)">
        Sort by Category
    </button>
    <button onclick="sort()">
        Clear
    </button>
</div>

上述代码生成了如下截图所示的排序后的水平条:

如何做...

基于数据的排序

工作原理...

在这个菜谱中,我们设置了一个简单的基于行的可视化(在行 BCD),包含一些模拟的个人消费记录,这些记录包含两个属性:expensecategory,它们在行 A 上定义。这与之前的菜谱完全相同,并且与我们在 将对象字面量绑定为数据 菜谱中所做的工作非常相似。一旦完成基础知识,我们就在行 E 上选择所有现有的条形图并使用 D3 selection.sort 函数进行排序:

d3.select("body")
    .selectAll("div.h-bar") 
 .sort(comparator); // <-E

selection.sort 函数接受一个比较器函数:

var compareByExpense = function (a, b) {  // <-F
    return a.expense < b.expense?-1:1;
};
var compareByCategory = function (a, b) {  // <-G
    return a.category < b.category?-1:1;
};

比较器函数接收两个要比较的数据元素 ab,返回一个负数、正数或零值。如果值为负数,则 a 将被放置在 b 之前;如果为正数,则 a 将被放置在 b 之后;否则,ab 被视为相等,顺序是任意的sort() 函数返回一个新的选择集,其中所有元素按指定的比较器函数确定的顺序排序。新返回的选择集可以进一步操作以生成所需的可视化。

小贴士

由于当 ab 相等时它们被任意放置,因此 D3 selection.sort 不保证是稳定的,然而,它保证与浏览器内置的数组 sort 方法一致。

从服务器加载数据

你可能很少只可视化静态本地数据。数据可视化的力量通常在于能够可视化由服务器端程序生成的动态数据。由于这是一个常见的用例,D3 提供了一些方便的辅助函数,以尽可能简化这项任务。在这个菜谱中,我们将看到如何动态加载远程数据集,并在数据加载后更新现有的可视化。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter3/asyn-data-load.html

如何做...

asyn-data-load.html 文件的代码示例中,我们将根据用户请求从服务器动态加载数据,一旦数据加载完成,我们还将更新我们的可视化以反映新的扩展数据集。以下是执行此操作的代码:

<script type="text/javascript">
    var data = [ // <-A
        {expense: 10, category: "Retail"},
        {expense: 15, category: "Gas"},
        {expense: 30, category: "Retail"},
        {expense: 50, category: "Dining"},
        {expense: 80, category: "Gas"},
        {expense: 65, category: "Retail"},
        {expense: 55, category: "Gas"},
        {expense: 30, category: "Dining"},
        {expense: 20, category: "Retail"},
        {expense: 10, category: "Dining"},
        {expense: 8, category: "Gas"}
    ];

    function render(data) {
        d3.select("#chart").selectAll("div.h-bar") // <-B
                .data(data)
            .enter().append("div")
            .attr("class", "h-bar")
            .append("span");

        d3.select("#chart").selectAll("div.h-bar") // <-C
                .data(data)
            .exit().remove();

        d3.select("#chart").selectAll("div.h-bar") // <-D
                .data(data)
            .attr("class", "h-bar")
            .style("width", function (d) {
                return (d.expense * 5) + "px";
            })
            .select("span")
                .text(function (d) {
                    return d.category;
                });
    }

    render(data);

 function load(){ // <-E
 d3.json("data.json", function(error, json){ // <-F
 data = data.concat(json); 
 render(data);
 });
 }
</script>

<div class="control-group">
    <button onclick="load()">Load Data from JSON feed</button>
</div>

这是我们 data.json 文件的样子:

[
    {"expense": 15,  "category": "Retail"},
 {"expense": 18,  "category": "Gas"},
 ...
 {"expense": 15, "category": "Gas"}
]

点击 从 JSON 提供程序加载数据 按钮一次后,这个菜谱将生成以下视觉输出:

如何做...

从服务器加载数据

工作原理...

在这个菜谱中,我们最初在标记为 A 的行上定义了一个本地数据集,以及由行 BCD 生成的基于行的可视化。load 函数在行 E 上定义,它响应用户点击 从 JSON 提供程序加载数据 按钮,该按钮从服务器提供的单独文件(data.json)加载数据。这是通过在行 F 上使用 d3.json 函数实现的:

    function load(){ // <-E
        d3.json("data.json", function(error, json){ // <-F
            data = data.concat(json);  
            render(data);
        });
 }

由于从 JSON 文件加载远程数据集可能需要一些时间,因此它是异步执行的。一旦加载完成,数据集将被传递给指定的处理函数,该函数在行 F 上指定。在这个函数中,我们只是将新加载的数据与现有数据集连接起来,然后重新渲染可视化以更新显示。

小贴士

D3 还提供了类似的功能,使得加载 CSV、TSV、TXT、HTML 和 XML 数据变得简单易行。

如果需要更定制化和具体的控制,可以使用 d3.xhr 函数来进一步自定义 MIME 类型和要求头。幕后,d3.jsond3.csv 都是在使用 d3.xhr 来生成实际请求。

当然,这绝对不是从服务器加载远程数据的唯一方法。D3 并不规定如何从远程服务器加载数据。你可以自由地使用你喜欢的 JavaScript 库,例如 jQuery 或 Zepto.js 来发起 Ajax 请求并加载远程数据集。

第四章。衡量尺度

在本章中,我们将涵盖:

  • 使用定量尺度

  • 使用时间尺度

  • 使用序数尺度

  • 插值字符串

  • 插值颜色

  • 插值复合对象

  • 实现自定义插值器

简介

作为数据可视化开发者,你需要反复执行的一个关键任务是不断地将数据域中的值映射到视觉域中,例如,将你最近购买的一块价值 453.00 美元的平板电脑映射为 653 像素长的条形,以及将你昨晚酒吧的消费 23.59 美元映射为 34 像素长的条形,分别。从某种意义上说,这就是数据可视化的全部——以高效和准确的方式将数据元素映射到它们的视觉隐喻。因为这是数据可视化和动画(动画将在第六章,“以风格过渡”中详细讨论)中绝对必要的任务,D3 提供了丰富且强大的支持,这是本章的重点。

什么是尺度?

D3 提供了各种称为尺度的结构来帮助您执行此类映射。对这些结构概念上的正确理解对于成为一名有效的可视化开发者至关重要。这是因为尺度不仅用于执行我们之前提到的映射,而且还作为许多其他 D3 结构(如过渡和坐标轴)的基本构建块。

这些尺度究竟是什么?

简而言之,尺度可以被视为数学函数。数学函数与在命令式编程语言(如 JavaScript 函数)中定义的函数不同。在数学中,函数被定义为两个集合之间的映射:

设 A 和 B 为非空集合。从 A 到 B 的函数 f 是将 B 中恰好一个元素分配给 A 中每个元素的赋值。我们写 f(a) = b,如果 b 是函数 f 分配给 A 中元素 a 的唯一元素。

(Rosen K. H. 2007)

尽管这个定义很枯燥,我们还是忍不住注意到它如何完美地符合我们需要执行的任务——将数据域中的元素映射到视觉域中。

我们在这里需要说明的另一个基本重要概念是给定函数的定义域值域

如果 f 是从 A 到 B 的函数,我们说 A 是 f定义域,B 是 f值域。如果 f(a) = b,我们说 b 是 a 的,a 是 b 的原像f值域是 A 中所有元素的像的集合。此外,如果 f 是从 A 到 B 的函数,我们说 f 将 A 映射到 B。

(Rosen K. H. 2007)

为了帮助我们理解这个概念,让我们看看以下插图:

什么是尺度?

函数 f 将 A 映射到 B

我们现在可以清楚地看到,在前面关于函数 f 的说明中,定义域设置为 A,范围设置为 B。想象一下,如果集合 A 代表我们的数据域,B 代表视觉域,那么在这里定义的函数 f 本质上就是 D3 中的一个尺度,它将集合 A 中的元素映射到集合 B。

注意

对于数学倾向的读者,数据可视化中的尺度函数通常是 一对一 但不是 满射 的函数。这是一个有用的见解,但不是本书目的的关键。因此,我们不会进一步讨论它。

现在,我们已经讨论了 D3 中尺度函数的概念定义,让我们看看它是如何帮助我们开发可视化项目的。

使用定量尺度

在这个菜谱中,我们将检查 D3 提供的最常用的尺度——包括线性、幂和对数尺度在内的定量尺度。

准备工作

在你的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter4/quantitative-scales.html

如何做...

让我们看看以下代码示例:

<div id="linear" class="clear"><span>n</span></div>
<div id="linear-capped" class="clear">
    <span>1 &lt;= a*n + b &lt;= 20</span>
</div>
<div id="pow" class="clear"><span>n²</span></div>
<div id="pow-capped" class="clear">
    <span>1 &lt;= a*n² + b &lt;= 10</span>
</div>
<div id="log" class="clear"><span>log(n)</span></div>
<div id="log-capped" class="clear">
    <span>1 &lt;= a*log(n) + b &lt;= 10</span>
</div>

<script type="text/javascript">
    var max = 11, data = [];
    for (var i = 1; i < max; ++i) data.push(i);

 var linear = d3.scale.linear() // <-A
 .domain([1, 10]) // <-B
 .range([1, 10]); // <-C 
 var linearCapped = d3.scale.linear()
 .domain([1, 10]) 
 .range([1, 20]); // <-D

 var pow = d3.scale.pow().exponent(2); // <-E
 var powCapped = d3.scale.pow() // <-F
 .exponent(2)
 .domain([1, 10])
 .rangeRound([1, 10]); // <-G

 var log = d3.scale.log(); // <-H
 var logCapped = d3.scale.log() // <-I
 .domain([1, 10])
 .rangeRound([1, 10]);

    function render(data, scale, selector) {
        d3.select(selector).selectAll("div.cell")
                .data(data)
                .enter().append("div").classed("cell", true);

        d3.select(selector).selectAll("div.cell")
                .data(data)
                .exit().remove();

        d3.select(selector).selectAll("div.cell")
                .data(data)
                .style("display", "inline-block")
 .text(function (d) {
 return d3.round(scale(d), 2);
 });
    }

    render(data, linear, "#linear");
    render(data, linearCapped, "#linear-capped");
    render(data, pow, "#pow");
    render(data, powCapped, "#pow-capped");
    render(data, log, "#log");
    render(data, logCapped, "#log-capped");
</script>

以下代码在你的浏览器中生成以下输出:

如何做...

定量尺度输出

它是如何工作的...

在这个菜谱中,我们展示了 D3 提供的一些最常见的尺度。

线性尺度

在前面的代码示例中,我们的数据数组填充了从 010 的整数——如标记为 A 的行所示——我们通过调用 d3.scale.linear() 函数创建了一个 线性尺度。这个函数返回一个默认域设置为 [0, 1]、默认范围设置为 [0, 1] 的线性定量尺度。因此,默认尺度本质上就是数字的 恒等函数。因此,这个默认函数对我们来说并不那么有用,但通常需要通过使用其 domainrange 函数在行 BC 上进行进一步定制。在这种情况下,我们将它们都设置为 [1, 10]。这个尺度基本上定义了函数 f(n) = n

    var linear = d3.scale.linear() // <-A
        .domain([1, 10]) // <-B
        .range([1, 10]); // <-C        

它是如何工作的...

标准尺度

第二个线性尺度更有趣一些,它更好地说明了两个集合之间的映射。在行 D 中,我们将范围设置为 [1, 20],这与它的定义域不同。因此,现在这个函数本质上代表以下方程:

  • f(n) = a * n + b

  • 1 <= f(n) <= 20

这无疑是使用 D3 尺度时最常见的情况,因为你的数据集将与你的视觉集完全匹配。

    var linearCapped = d3.scale.linear()
        .domain([1, 10])        
        .range([1, 20]); // <-D

它是如何工作的...

线性尺度

在这个第二个尺度中,D3 将自动计算并分配常数 ab 的值以满足方程。

注意

一些基本的代数计算将告诉你,a 大约是 2.11,b 是 -1.11,就像前面的例子一样。

幂尺度

我们创建的第二种尺度是一种 幂尺度。在线 E 上,我们定义了一个指数为 2 的幂尺度。d3.scale.pow() 函数返回一个默认的幂尺度函数,其 指数 设置为 1。此尺度有效地定义了函数 f(n) = n²

    var pow = d3.scale.pow().exponent(2); // <-E

如何工作...

简单的幂尺度

在线 F 上定义了第二个幂尺度,这次在 G 线上设置了不同的范围并进行四舍五入;rangeRound() 函数基本上与 range() 函数相同,它为尺度设置范围。然而,rangeRound 函数将输出数字四舍五入,以便没有小数部分。这非常方便,因为尺度通常用于将数据域中的元素映射到视觉域。因此,尺度的输出很可能是一个描述某些视觉特征的数字,例如像素数。避免亚像素数是一种有用的技术,可以防止渲染时的反走样。

第二个幂尺度定义了以下函数 f(n) = an² + b, 1 <= f(n) <= 10*。

    var powCapped = d3.scale.pow() // <-F
        .exponent(2)
        .domain([1, 10])
        .rangeRound([1, 10]); // <-G

如何工作...

幂尺度

与线性尺度类似,D3 将自动找到合适的常数 ab,以满足幂尺度上由 domainrange 定义的约束。

对数尺度

在线 H 上,使用 d3.scale.log() 函数创建了一种第三种类型的定量尺度。默认对数尺度的基础值为 10。线 H 实质上定义了以下数学函数 f(n) = log(n)

    var log = d3.scale.log(); // <-H

如何工作...

简单对数尺度

在线 I 上,我们自定义了对数尺度,使其域为 [1, 10],范围四舍五入为 [1, 10],这定义了以下约束数学函数 f(n) = alog(n) + b, 1 <= f(n) <= 10*。

    var logCapped = d3.scale.log() // <-I
        .domain([1, 10])
        .rangeRound([1, 10]);

如何工作...

对数尺度

更多...

D3 还提供了其他额外的定量尺度,包括量化、阈值、分位数和恒等尺度。由于本书的范围有限且它们的使用相对较少,这里没有讨论,然而,对这里讨论和解释的尺度的基本理解将肯定有助于您理解 D3 库提供的其他额外定量尺度。有关其他类型定量尺度的更多信息,请访问 github.com/mbostock/d3/wiki/Quantitative-Scales#wiki-quantitative

使用时间尺度

通常,我们对时间敏感和日期敏感的数据集进行数据分析,因此,D3 提供了一个内置的时间尺度来帮助执行此类映射。在本教程中,我们将学习如何使用 D3 时间尺度。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter4/time-scale.html

如何操作...

首先,让我们看一下以下代码示例:

<div id="time" class="clear">
    <span>Linear Time Progression<br></span>
    <span>Mapping [01/01/2013, 12/31/2013] to [0, 900]<br></span>
</div>

<script type="text/javascript">
 var start = new Date(2013, 0, 1), // <-A 
 end = new Date(2013, 11, 31),
 range = [0, 1200],
 time = d3.time.scale().domain([start, end]) // <-B
 .rangeRound(range), // <-C
        max = 12,
        data = [];

 for (var i = 0; i < max; ++i){ // <-D
 var date = new Date(start.getTime());
 date.setMonth(start.getMonth() + i);
 data.push(date);
 }

    function render(data, scale, selector) { // <-E
        d3.select(selector).selectAll("div.fixed-cell")
                    .data(data)
                .enter()
                    .append("div").classed("fixed-cell", true);

        d3.select(selector).selectAll("div.fixed-cell")
                    .data(data)
                .exit().remove();

        d3.select(selector).selectAll("div.fixed-cell")
                    .data(data)
 .style("margin-left", function(d){ // <-F
 return scale(d) + "px";
 })
 .html(function (d) { // <-G
 var format = d3.time.format("%x"); // <-H
 return format(d) + "<br>" + scale(d) + "px";
 });
    }

    render(data, time, "#time");
</script>

此菜谱生成了以下视觉输出:

如何做...

时间尺度

它是如何工作的...

在这个菜谱中,我们在行 A 上定义了一个日期范围,从 2013 年 1 月 1 日到 2013 年 12 月 31 日。

var start = new Date(2013, 0, 1), // <-A 
        end = new Date(2013, 11, 31),
        range = [0, 900],
        time = d3.time.scale().domain([start, end]) // <-B
            .rangeRound(range), // <-C

小贴士

JavaScript Date对象从 0 开始月份,从 1 开始日期。因此,new Date(2013, 0, 1) 给你 2013 年 1 月 1 日,而 new Date(2013, 0, 0) 实际上给你 2012 年 12 月 31 日。

然后使用 d3.time.scale 函数在行 B 上创建了一个 D3 时间尺度,这个范围被用来创建时间尺度。与定量尺度类似,时间尺度也支持单独的 domainrange 定义,用于将基于日期和时间的点映射到视觉范围。在此示例中,我们将尺度的范围设置为 [0, 900]。这实际上定义了从 2013 年 1 月 1 日到 2013 年 12 月 31 日之间的任何日期和时间值到 0 到 900 之间的数字的映射。

定义了时间尺度后,我们现在可以通过调用尺度函数将任何给定的 Date 对象映射,例如,time(new Date(2013, 4, 1)) 将返回 395time(new Date(2013, 11, 15)) 将返回 1147,依此类推。

在行 D 上,我们创建了一个数据数组,包含 2013 年 1 月到 12 月的 12 个月份:

    for (var i = 0; i < max; ++i){ // <-D
        var date = new Date(start.getTime());
        date.setMonth(start.getMonth() + i);
        data.push(date);
    }

然后,在行 E 上,我们使用 render 函数创建了 12 个单元格,代表一年中的每个月份。

为了水平展开单元格,行 F 使用我们定义的时间尺度将月份映射到 margin-left CSS 样式:

.style("margin-left", function(d){ // <-F
    return scale(d) + "px";
})

G 生成标签以展示在此示例中基于缩放的映射产生的结果:

.html(function (d) { // <-G
    var format = d3.time.format("%x"); // <-H
    return format(d) + "<br>" + scale(d) + "px";
});

要从 JavaScript Date对象生成可读的字符串,我们在行 H 上使用了 D3 时间格式化器。D3 附带了一个强大且灵活的时间格式化库,当处理Date对象时非常有用。

还有更多...

这里有一些最有用的 d3.time.format 模式:

  • %a: 这是以缩写形式表示的星期名称

  • %A: 这是以全称形式表示的星期名称

  • %b: 这是以缩写形式表示的月份名称

  • %B: 这是以全称形式表示的月份名称

  • %d: 这是以十进制数字表示的零填充的月份天数 [01,31]

  • %e: 这是以空格填充的月份天数,以十进制数字表示 [ 1,31]

  • %H: 这是以十进制数字表示的小时(24 小时制)[00,23]

  • %I: 这是以十进制数字表示的小时(12 小时制)[01,12]

  • %j: 这是以十进制数字表示的年份中的天数 [001,366]

  • %m: 这是以十进制数字表示的月份 [01,12]

  • %M: 这是以十进制数字表示的分钟 [00,59]

  • %L: 这是以十进制数字表示的毫秒数 [000, 999]

  • %p: 这表示上午或下午

  • %S: 这是以十进制数字表示的秒 [00,61]

  • %x: 这是以 "%m/%d/%Y" 格式表示的日期

  • %X: 这是以 "%H:%M:%S" 格式表示的时间

  • %y: 这是以十进制数字表示的没有世纪的年份 [00,99]

  • %Y: 这是以十进制数字表示的包含世纪的年份

参见

  • 要查看 D3 时间格式模式的完整参考,请访问以下链接:

    时间格式化

使用序数等级

在某些情况下,我们可能需要将我们的数据映射到某些序数值,例如,["a", "b", "c"]["#1f77b4", "#ff7f0e", "#2ca02c"]。那么,我们如何使用 D3 等级来执行这种映射?本食谱旨在回答这类问题。

准备工作

在你的网络浏览器中打开以下文件的本地副本:

序数等级示例

如何做到这一点...

这种序数映射在数据可视化中相当常见。例如,你可能想要通过分类将某些数据点映射到某些文本值或 RGB 颜色代码,这反过来又可以用于 CSS 样式。D3 提供了一种专门的等级实现来处理这种映射。我们将在下面探讨其用法。以下是ordinal.scale.html文件的代码:

<div id="alphabet" class="clear">
    <span>Ordinal Scale with Alphabet</span>
    <span>Mapping [1..10] to ["a".."j"]</span>
</div>
<div id="category10" class="clear">
    <span>Ordinal Color Scale Category 10</span>
    <span>Mapping [1..10] to category 10 colors</span>
</div>
<div id="category20" class="clear">
    <span>Ordinal Color Scale Category 20</span>
    <span>Mapping [1..10] to category 20 colors</span>
</div>
<div id="category20b" class="clear">
    <span>Ordinal Color Scale Category 20b</span>
    <span>Mapping [1..10] to category 20b colors</span>
</div>
<div id="category20c" class="clear">
    <span>Ordinal Color Scale Category 20c</span>
    <span>Mapping [1..10] to category 20c colors</span>
</div>

<script type="text/javascript">
    var max = 10, data = [];

    for (var i = 0; i < max; ++i) data.push(i); // <-A

 var alphabet = d3.scale.ordinal() // <-B
 .domain(data)
 .range(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]);

    function render(data, scale, selector) { // <-C
        d3.select(selector).selectAll("div.cell")
                  .data(data)
              .enter().append("div").classed("cell", true);

        d3.select(selector).selectAll("div.cell")
                  .data(data)
              .exit().remove();

        d3.select(selector).selectAll("div.cell")
                  .data(data)
              .style("display", "inline-block")
 .style("background-color", function(d){  // <-D
 return scale(d).indexOf("#")>=0?scale(d):"white";
 })
 .text(function (d) { // <-E
 return scale(d);
 });
    }

    render(data, alphabet, "#alphabet"); // <-F
    render(data, d3.scale.category10(), "#category10");
    render(data, d3.scale.category20(), "#category20");
    render(data, d3.scale.category20b(), "#category20b");
    render(data, d3.scale.category20c(), "#category20c"); // <-G
</script>

上述代码在你的浏览器中输出以下内容:

如何做到这一点...

序数等级

如何工作...

在上述代码示例中,第A行定义了一个简单的数据数组,包含从09的整数:

for (var i = 0; i < max; ++i) data.push(i); // <-A    
var alphabet = d3.scale.ordinal() // <-B
    .domain(data)
.range(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]);

然后在第B行使用d3.scale.ordinal函数创建了一个序数等级。这个等级的定义域被设置为我们的整数数组数据,而范围被设置为从aj的字母列表。

定义了这个等级后,我们可以通过简单地调用等级函数来进行映射,例如,alphabet(0)将返回aalphabet(4)将返回e,依此类推。

在第C行,定义了render函数以在页面上生成多个div元素来表示数据数组中的 10 个元素。每个divbackground-color被设置为等级函数的输出或white,如果输出不是 RGB 颜色字符串:

.style("background-color", function(d){  // <-D
    return scale(d).indexOf("#")>=0 ? scale(d) : "white";
})

在第E行,我们还设置了每个单元格的文本以显示等级函数的输出:

.text(function (d) { // <-E
    return scale(d);
});

现在,所有结构都已就绪,从第F行到第G行,render函数被重复调用,使用不同的序数等级来产生不同的视觉输出。在第F行,调用renderalphabet序数等级产生以下输出:

如何工作...

字母序数等级

当在第G行调用内置的d3.scale.category20c序数颜色等级的render函数时,会产生以下输出:

如何工作...

颜色序数等级

由于在可视化中将不同颜色分配给不同的元素是一个常见任务,例如,在饼图和气泡图中分配不同的颜色,D3 提供了一系列不同的内置序数颜色等级,正如我们在本食谱中看到的。

构建自己的简单自定义序数颜色尺度相当容易。只需创建一个范围设置为所需颜色的序数尺度,例如:

d3.scale.ordinal()
.range(["#1f77b4", "#ff7f0e", "#2ca02c"]);

字符串插值

在某些情况下,你可能需要插值字符串中嵌入的数字;例如,字体样式的 CSS。

在这个菜谱中,我们将探讨如何使用 D3 规模和插值来完成这项工作。然而,在我们直接进入字符串插值之前,需要对插值器进行一些背景研究,接下来的部分将介绍什么是插值以及 D3 如何实现插值函数。

插值器

在前三个菜谱中,我们已经介绍了三种不同的 D3 规模实现,现在是时候深入探讨 D3 规模了。你可能已经提出了问题,“不同的规模如何知道对不同输入使用什么值?”实际上,这个问题可以概括为:

我们给出了函数 f(x) 在不同点 x0, x1, … ,xn 的值。我们想要找到函数 f(x) 在这些点之间“新”的 x 的近似值。这个过程被称为 插值

Kreyszig E & Kreyszig H & Norminton E. J. (2010)

插值不仅在规模实现中很重要,而且对于许多其他核心 D3 功能也是必不可少的,例如动画和布局管理。正因为这个基本作用,D3 设计了一个单独且可重用的结构,称为 插值器,以便这个常见的跨功能问题可以集中和一致地解决。让我们以一个简单的插值器为例:

var interpolate = d3.interpolateNumber(0, 100);
interpolate(0.1); // => 10
interpolate(0.99); //=> 99

在这个简单的例子中,我们创建了一个范围在 [0, 100] 的 D3 数字插值器。d3.interpolateNumber 函数返回一个 interpolate 函数,我们可以使用它来执行基于数字的插值。interpolate 函数等同于以下代码:

function interpolate(t) {
    return a * (1 - t) + b * t;
}

在这个函数中,a 代表范围的起始值,b 代表范围的结束值。传递给 interpolate() 函数的参数 t 是一个介于 0 到 1 之间的浮点数,它表示返回值与 a 的距离。

D3 提供了许多内置的插值器。由于本书的范围有限,我们将专注于接下来几个菜谱中一些更有趣的插值器;我们在这里结束对简单数字插值的讨论。尽管如此,无论是数字还是 RGB 颜色代码插值器,基本方法和机制都是相同的。

注意

关于数字和四舍五入插值的更多详细信息,请参阅 D3 参考文档github.com/mbostock/d3/wiki/Transitions#wiki-d3_interpolateNumber

现在我们已经了解了通用的插值概念,让我们来看看 D3 中字符串插值器是如何工作的。

准备工作

在您的网页浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter4/string-interpolation.html

如何操作...

字符串插值器会找到字符串中嵌入的数字,然后使用 D3 数字插值器进行插值:

<div id="font" class="clear">
    <span>Font Interpolation<br></span>
</div>

<script type="text/javascript">
    var max = 11, data = [];

 var sizeScale = d3.scale.linear() // <-A
 .domain([0, max])
 .range([  // <-B
 "italic bold 12px/30px Georgia, serif", 
 "italic bold 120px/180px Georgia, serif"
 ]);

    for (var i = 0; i < max; ++i){ data.push(i); }

    function render(data, scale, selector) { // <-C
        d3.select(selector).selectAll("div.cell")
                .data(data)
            .enter().append("div").classed("cell", true)
                .append("span");

        d3.select(selector).selectAll("div.cell")
                .data(data)
            .exit().remove();

        d3.select(selector).selectAll("div.cell")
                .data(data)
            .style("display", "inline-block")
            .select("span")
 .style("font", function(d,i){ 
 return scale(d); // <-D
 })
 .text(function(d,i){return i;}); // <-E
    }

    render(data, sizeScale, "#font");
</script>

上述代码产生了以下输出:

如何操作...

字符串插值

工作原理...

在这个例子中,我们在行 A 上创建了一个线性比例尺,其范围由表示起始和结束 font 样式的两个字符串指定:

var sizeScale = d3.scale.linear() // <-A
        .domain([0, max])
        .range([  // <-B
            "italic bold 12px/30px Georgia, serif", 
            "italic bold 120px/180px Georgia, serif"
        ]);

如您在 string-interpolation.html 文件中的代码所见,font 样式字符串包含 font-size 数字 12px/30px120px/180px,这是我们在这个菜谱中想要插值的。

在行 C 上,render() 函数简单地创建了包含每个索引数字(行 E)的 10 个单元格,这些单元格使用在行 D 上计算的插值 font 样式字符串进行样式化。

.style("font", function(d,i){ 
    return scale(d); // <-D
})
.text(function(d,i){return i;}); // <-E

更多内容...

尽管我们使用 CSS 字体样式作为示例演示了 D3 中的字符串插值,但 D3 字符串插值器不仅限于处理 CSS 样式。它可以基本上处理任何字符串,并且只要数字与以下 正则表达式模式 匹配,就会插值嵌入的数字:

/[-+]?(?:\d+\.?\d*|\.?\d+)(?:[eE][-+]?\d+)?/g

小贴士

当使用插值生成字符串时,非常小的值在转换为字符串时可能会被转换为科学记数法,例如,1e-7。为了避免这种特定的转换,您需要确保您的值大于 1e-6。

颜色插值

有时在插值不包含数字而包含 RGB 或 HSL 颜色代码的值时,需要插值颜色。这个菜谱解决了如何定义颜色代码的比例尺并在其上进行插值的问题。

准备工作

在您的网页浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter4/color-interpolation.html

如何操作...

颜色插值在可视化中是一个非常常见的操作,D3 实际上提供了四种不同类型的插值器,专门用于支持颜色——RGB、HSL、Lab* 和 HCL 颜色空间。在这个菜谱中,我们将演示如何在 RGB 颜色空间中执行颜色插值。然而,所有其他颜色插值器的工作方式相同。

小贴士

D3 颜色插值函数总是返回在 RGB 空间中的插值颜色,无论原始颜色空间是什么,因为并非所有浏览器都支持 HSL 或 Lab* 颜色空间。

<div id="color" class="clear">
    <span>Linear Color Interpolation<br></span>
</div>
<div id="color-diverge" class="clear">
    <span>Poly-Linear Color Interpolation<br></span>
</div>

<script type="text/javascript">
    var max = 21, data = [];

 var colorScale = d3.scale.linear() // <-A
 .domain([0, max])
 .range(["white", "#4169e1"]);

 function divergingScale(pivot) { // <-B
 var divergingColorScale = d3.scale.linear()
 .domain([0, pivot, max]) // <-C
 .range(["white", "#4169e1", "white"]);
 return divergingColorScale;
 }

    for (var i = 0; i < max; ++i) data.push(i);

    function render(data, scale, selector) { // <-D
        d3.select(selector).selectAll("div.cell")
                .data(data)
            .enter()
                .append("div")
                    .classed("cell", true)
                .append("span");

        d3.select(selector).selectAll("div.cell")
                .data(data)
            .exit().remove();

        d3.select(selector).selectAll("div.cell")
                .data(data)
            .style("display", "inline-block")
 .style("background-color", function(d){
 return scale(d); // <-E
 })
            .select("span")
                .text(function(d,i){return i;});
    }

    render(data, colorScale, "#color");
    render(data, divergingScale(5), "#color-diverge");
</script>

上述代码产生了以下视觉输出:

如何操作...

颜色插值

工作原理...

这个菜谱的第一步是在行 A 上定义一个线性颜色比例尺,其范围设置为 ["white", "#4169e1"]

var colorScale = d3.scale.linear() // <-A
    .domain([0, max])
    .range(["white", "#4169e1"]);

在这个菜谱中使用了我们尚未遇到的一种新技术,即 多线性刻度,它在行 B 上的 divergingScale 函数中定义。

function divergingScale(pivot) { // <-B
    var divergingColorScale = d3.scale.linear()
        .domain([0, pivot, max]) // <-C
        .range(["white", "#4169e1", "white"]);
    return divergingColorScale;
}

多线性刻度是一个具有非均匀线性进度的刻度。它通过在线性刻度上提供一个多线性域来实现,正如我们在行 C 上所看到的。你可以将多线性刻度视为将具有不同域的两个线性刻度拼接在一起。因此,这个多线性颜色刻度实际上是以下两个线性刻度的组合。

d3.scale.linear()
    .domain([0, pivot]).range(["white", "#4169e1"]);
d3.scale.linear()
.domain([pivot, max]).range(["#4169e1", "white "]);

在菜谱的其余部分没有惊喜。在行 D 上定义的 render() 函数生成 20 个单元格,这些单元格按其索引编号,并使用我们之前定义的两个颜色刻度的输出进行着色。点击网页上的按钮(例如 在 5 处旋转)将显示在多线性颜色刻度中不同位置旋转的效果。

参见

复合对象插值

在某些情况下,你可能需要在你的可视化中插值的内容不是一个简单的值,而是一个由多个不同值组成的对象,例如,具有宽度、高度和颜色属性的矩形对象。幸运的是,D3 对这种复合对象插值有内置的支持。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter4/compound-interpolation.html

如何做...

在这个菜谱中,我们将探讨如何在 D3 中执行复合对象插值。compound-interpolation.html 文件的代码如下:

<div id="compound" class="clear">
    <span>Compound Interpolation<br></span>
</div>

<script type="text/javascript">
    var max = 21, data = [];

 var compoundScale = d3.scale.pow()
 .exponent(2)
 .domain([0, max])
 .range([
 {color:"#add8e6", height:"15px"}, // <-A
 {color:"#4169e1", height:"150px"} // <-B
 ]);

    for (var i = 0; i < max; ++i) data.push(i);

    function render(data, scale, selector) { // <-C
        d3.select(selector).selectAll("div.v-bar")
                .data(data)
                .enter().append("div").classed("v-bar", true)
                .append("span");

        d3.select(selector).selectAll("div.v-bar")
                .data(data)
                .exit().remove();

        d3.select(selector).selectAll("div.v-bar")
                .data(data)
                .classed("v-bar", true)
 .style("height", function(d){ // <-D
 return scale(d).height;
 }) 
 .style("background-color", function(d){ // <-E
 return scale(d).color;
 })
                .select("span")
                .text(function(d,i){return i;});
    }

    render(data, compoundScale, "#compound");
</script>

上述代码生成了以下视觉输出:

如何做...

复合对象插值

它是如何工作的...

这个菜谱与本章前面的菜谱不同之处在于,我们在这个菜谱中使用的刻度范围是由两个对象定义的,而不是简单的原始数据类型:

var compoundScale = d3.scale.pow()
            .exponent(2)
            .domain([0, max])
            .range([
                {color:"#add8e6", height:"15px"}, // <-A
                {color:"#4169e1", height:"150px"} // <-B
            ]);

我们可以在行 AB 上看到,刻度范围的起始和结束是包含两种不同类型值的两个对象;一个用于 RGB 颜色,另一个用于 CSS 高度样式。当你插值这种包含复合范围的刻度时,D3 将遍历对象内的每个字段,并递归地对每个字段应用简单的插值规则。换句话说,对于这个例子,D3 将使用颜色插值从 #add8e6#4169e1 插值 color 字段,同时在高度字段上使用字符串插值从 15px150px

小贴士

该算法的递归性质允许 D3 在嵌套对象上进行插值。因此,你可以对如下对象进行插值:

{
  color:"#add8e6", 
  size{ 
height:"15px", 
width: "25px"
  }
}

当调用复合比例函数时,它返回一个与给定范围定义相匹配的复合对象:

.style("height", function(d){
  return scale(d).height; // <-D
}) 
.style("background-color", function(d){
  return scale(d).color; // <-E
})

如我们在行 DE 上所见,返回值是一个复合对象,这就是为什么我们可以访问其属性来检索插值值。

提示

虽然这不是一个常见的案例,但如果你的复合比例范围的起始和结束没有相同的属性,D3 不会抱怨,而是将缺失的属性视为一个常数。以下比例将使所有 div 元素的高度渲染为 15px

var compoundScale = d3.scale.pow()
            .exponent(2)
            .domain([0, max])
            .range([
                {color:"#add8e6", height:"15px"}, // <-A
                {color:"#4169e1"} // <-B
            ]);

实现自定义插值器

在某些罕见的情况下,你可能会发现内置的 D3 插值器不足以处理你的可视化需求。在这种情况下,你可以选择实现自己的插值器,使用特定的逻辑来处理你的需求。在这个菜谱中,我们将检查这种方法并展示一些有趣的用例。

准备工作

在你的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter4/custom-interpolator.html

如何做...

让我们看看两个不同自定义插值器实现的例子。在第一个例子中,我们将实现一个能够插值美元价格的自定义函数,而在第二个例子中,我们将实现字母的自定义插值器。以下是实现此功能的代码:

<div id="dollar" class="clear">
    <span>Custom Dollar Interpolation<br></span>
</div>
<div id="alphabet" class="clear">
    <span>Custom Alphabet Interpolation<br></span>
</div>

<script type="text/javascript">
 d3.interpolators.push(function(a, b) { // <-A
 var re = /^\$([0-9,.]+)$/, // <-B
 ma, mb, f = d3.format(",.02f"); 
 if ((ma = re.exec(a)) && (mb = re.exec(b))) { // <-C
 a = parseFloat(ma[1]);
 b = parseFloat(mb[1]) - a;  // <-D
 return function(t) {  // <-E
 return "$" + f(a + b * t); // <-F
 };
 }
 });

 d3.interpolators.push(function(a, b) { // <-G
 var re = /^([a-z])$/, ma, mb; // <-H
 if ((ma = re.exec(a)) && (mb = re.exec(b))) { // <-I
 a = a.charCodeAt(0);
 var delta = a - b.charCodeAt(0); // <-J
 return function(t) { // <-K
 return String.fromCharCode(Math.ceil(a - delta * t));
 };
 }
 });

 var dollarScale = d3.scale.linear()
 .domain([0, 11])
 .range(["$0", "$300"]); // <-L

 var alphabetScale = d3.scale.linear()
 .domain([0, 27])
 .range(["a", "z"]); // <-M

    function render(scale, selector) {        
        var data = [];
        var max = scale.domain()[1];

        for (var i = 0; i < max; ++i) data.push(i);      

        d3.select(selector).selectAll("div.cell")
                    .data(data)
                .enter()
                    .append("div")
                        .classed("cell", true)
                    .append("span");

        d3.select(selector).selectAll("div.cell")
                    .data(data)
                .exit().remove();

        d3.select(selector).selectAll("div.cell")
                .data(data)
                .style("display", "inline-block")
                .select("span")
 .text(function(d,i){return scale(d);}); // <-N
    }

    render(dollarScale, "#dollar");
    render(alphabetScale, "#alphabet");
</script>

上述代码生成了以下视觉输出:

如何做...

自定义插值

它是如何工作的...

在这个菜谱中,我们遇到的第一个自定义插值器是在行 A 上定义的。自定义插值器函数稍微复杂一些,所以,让我们更仔细地看看它是如何工作的:

d3.interpolators.push(function(a, b) { // <-A
      var re = /^\$([0-9,.]+)$/, // <-B
        ma, mb, f = d3.format(",.02f"); 
      if ((ma = re.exec(a)) && (mb = re.exec(b))) { // <-C
        a = parseFloat(ma[1]);
        b = parseFloat(mb[1]) - a;  // <-D
        return function(t) {  // <-E
          return "$" + f(a + b * t); // <-F
        };
      }
    });

注意

以下链接中的自定义插值器直接从 D3 Wiki 中提取:

github.com/mbostock/d3/wiki/Transitions#wiki-d3_interpolators

在行 A 上,我们将一个插值函数推入 d3.interpolators。这是一个全局插值器注册数组,包含所有已知的注册插值器。默认情况下,此注册表中包含以下插值器:

  • 数字插值器

  • 字符串插值器

  • 颜色插值器

  • 对象插值器

  • 数组插值器

任何新的自定义插值器实现都可以推送到插值器数组的末尾,这使得它成为全局可用的。预期的插值器函数是一个工厂函数,它接受范围的起始值(a)和结束值(b)作为输入参数,并返回插值函数的实现,正如第E行所示。你可能想知道当呈现某个字符串值时,D3 是如何知道使用哪个插值器的。这个问题的关键在于第B行。通常我们使用一个名为re的变量,它被定义为正则表达式模式/^\$([0-9,.]+)$/,然后用于匹配任何以美元符号开头的数字的参数ab。如果这两个参数都匹配给定的模式,则构建并返回匹配的插值函数;否则,D3 将继续迭代d3.interpolators数组以找到合适的插值器。

与数组不同,d3.interpolators实际上更应被视为一个 FILO 栈(尽管并非完全按栈实现),新插值器可以被推送到栈顶。当选择插值器时,D3 会从顶部弹出并检查每个合适的插值器。因此,在这种情况下,后来推送到栈中的插值器具有优先权。

在第E行创建的匿名interpolate()函数接受一个名为t的单个参数,其值介于01之间,表示插值值与基础值a的偏差程度。

return function(t) {  // <-E
          return "$" + f(a + b * t); // <-F
        };

你可以将其视为从ab的期望值所经过距离的百分比。考虑到这一点,就可以清楚地看出,在第F行它执行插值并基于偏移量t计算期望值,这实际上插值了一个价格字符串。

小贴士

在这里需要注意的一点是,第D行中b参数的值已经被从范围的末尾更改为ab之间的差值。

b = parseFloat(mb[1]) - a;  // <-D

这通常被认为是一种不便于阅读的坏做法。因此,在你的实现中,你应该避免在函数中修改输入参数的值。

在第G行,注册了一个第二个自定义插值器来处理从az的单个字符小写字母:

d3.interpolators.push(function(a, b) { // <-G
      var re = /^([a-z])$/, ma, mb; // <-H
      if ((ma = re.exec(a)) && (mb = re.exec(b))) { // <-I
        a = a.charCodeAt(0);
        var delta = a - b.charCodeAt(0); // <-J
        return function(t) { // <-K
          return String.fromCharCode(Math.ceil(a - delta * t));
        };
      }
});

我们很快注意到这个插值器函数与之前的非常相似。首先,它在第H行定义了一个正则表达式模式,用于匹配单个小写字母。在执行匹配操作的第I行之后,范围的起始和结束值ab都被从字符值转换为整数值。在第J行计算了ab之间的差值。插值函数再次严格遵循与第一个插值器相同的公式,如第K行所示。

一旦这些自定义插值器被注册到 D3 中,我们就可以定义具有相应范围的刻度,而无需做任何额外的工作,我们还将能够插值它们的值:

var dollarScale = d3.scale.linear()
        .domain([0, 11])
        .range(["$0", "$300"]); // <-L

var alphabetScale = d3.scale.linear()
        .domain([0, 27])
        .range(["a", "z"]); // <-M

如预期,dollarScale函数将自动使用价格插值器,而alphabetScale函数将分别使用我们的字母插值器。在调用缩放函数以获取所需值时,不需要做额外的工作,正如在行N中所示:

.text(function(d,i){
  return scale(d);} // <-N
); 

在孤立状态下,自定义插值器似乎不是一个非常重要的概念;然而,在后续探索第六章中其他 D3 概念的第六章,以风格进行转换时,我们将探讨当自定义插值器与其他 D3 结构结合时,实现有趣的自定义效果时更强大的技术。

参见

  • 如果本章中使用的正则表达式是一个新概念或你工具箱中的已知工具,并且你需要一点点的复习,你可以在www.regular-expressions.info找到很多有用的资源。

第五章. 玩转轴

在本章中,我们将涵盖以下内容:

  • 使用基本轴

  • 自定义刻度

  • 绘制网格线

  • 轴的动态缩放

简介

D3 最初发布时没有内置的轴组件支持。这种情况并没有持续太久;由于轴是许多基于笛卡尔坐标系的可视化项目的通用构建块,很快就很清楚 D3 需要提供轴的内置支持。因此,它很早就被引入,并且自发布以来一直在不断改进。在本章中,我们将探讨轴组件的使用和一些相关技术。

使用基本轴

在本食谱中,我们将专注于介绍 D3 轴组件的基本概念和支持,同时涵盖轴的不同类型和功能以及它们的 SVG 结构。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter5/basic-axes.html

如何操作...

让我们先看看以下代码示例:

<div class="control-group">
    <button onclick="renderAll('bottom')">
        horizontal bottom
    </button>
    <button onclick="renderAll('top')">
        horizontal top
    </button>
    <button onclick="renderAll('left')">
        vertical left
    </button>
    <button onclick="renderAll('right')">
        vertical right
    </button>
</div>

<script type="text/javascript">
    var height = 500, 
        width = 500, 
        margin = 25,
        offset = 50,
        axisWidth = width - 2 * margin,
        svg;

 function createSvg(){ // <-A
 svg = d3.select("body").append("svg") // <-B
 .attr("class", "axis") // <-C
            .attr("width", width)
            .attr("height", height);
    }

    function renderAxis(scale, i, orient){
 var axis = d3.svg.axis() // <-D
 .scale(scale) // <-E
 .orient(orient) // <-F
 .ticks(5); // <-G

        svg.append("g")        
 .attr("transform", function(){ // <-H
 if(["top", "bottom"].indexOf(orient) >= 0)
 return "translate("+margin+","+i*offset+")";
 else
 return "translate("+i*offset+", "+margin+")";
 })
 .call(axis); // <-I
    }

    function renderAll(orient){
        if(svg) svg.remove();

        createSvg();

        renderAxis(d3.scale.linear()
                    .domain([0, 1000])
                    .range([0, axisWidth]), 1, orient);
        renderAxis(d3.scale.pow()
                    .exponent(2)
                    .domain([0, 1000])
                    .range([0, axisWidth]), 2, orient);
        renderAxis(d3.time.scale()
                    .domain([new Date(2012, 0, 1), new Date()])
                    .range([0, axisWidth]), 3, orient);
    }
</script>

上述代码产生了一个只显示以下截图中的四个按钮的视觉输出。一旦你点击 水平底部,它将显示以下内容:

如何操作...

水平轴

如何操作...

垂直轴

工作原理...

本食谱的第一步是创建一个将用于渲染我们的轴的 svg 元素。这是通过定义在行 A 上的 createSvg 函数完成的,并使用 D3 的 appendattr 修改器函数,如行 BC 所示。

注意

这是本书中第一个使用 SVG 而不是 HTML 元素的食谱,因为 D3 轴组件仅支持 SVG。如果你不熟悉 SVG 标准,不要担心;我们将在 第七章 中详细介绍它,进入形状。而在此章中,我们将介绍一些基本和有限的 SVG 概念,当它们被 D3 轴组件使用时。

让我们看看以下代码中我们是如何创建 SVG 画布的:

var height = 500, 
  width = 500, 
  margin = 25,
  offset = 50,
  axisWidth = width - 2 * margin,
  svg;

function createSvg(){ // <-A
     svg = d3.select("body").append("svg") // <-B
        .attr("class", "axis") // <-C
        .attr("width", width)
        .attr("height", height);
} 

现在,我们已经准备好在这个 svg 画布上渲染轴。renderAxis 函数正是为此而设计的。在行 D 上,我们首先使用 d3.svg.axis 函数创建一个轴组件:

var axis = d3.svg.axis() // <-D
            .scale(scale) // <-E
            .orient(orient) // <-F
            .ticks(5); // <-G

D3 轴被设计为与 D3 定量、时间和序数刻度无缝工作。轴刻度是通过 scale() 函数提供的(见行 E)。在这个例子中,我们使用以下刻度渲染了三个不同的轴:

d3.scale.linear().domain([0, 1000]).range([0, axisWidth])
d3.scale.pow().exponent(2).domain([0, 1000]).range([0, axisWidth])
d3.time.scale()
  .domain([new Date(2012, 0, 1), new Date()])
  .range([0, axisWidth])

我们对 axis 对象进行的第二次定制是其 orientorient 告诉 D3 这个轴将如何放置(方向),因此,它应该如何渲染,是水平还是垂直。D3 支持四种不同的轴 orient 配置:

  • top:一个水平轴,标签位于轴的顶部

  • bottom:一个水平轴,标签位于轴的底部

  • left:一个垂直轴,标签位于轴的左侧

  • right:一个垂直轴,标签位于轴的右侧

在行 G,我们将刻度数设置为 5。这告诉 D3,理想情况下我们希望为此轴渲染多少个刻度,然而,D3 可能会选择根据可用空间和自己的计算渲染稍微更多或更少的刻度。我们将在下一食谱中详细探讨轴刻度配置。

一旦定义了轴,创建过程中的最后一步是创建一个 svg:g 容器元素,然后它将被用来托管渲染轴所需的所有 SVG 结构:

svg.append("g")        
  .attr("transform", function(){ // <-H
    if(["top", "bottom"].indexOf(orient) >= 0)
      return "translate(" + margin + ","+ i * offset + ")";
    else
      return "translate(" + i * offset + ", " + margin + ")";
    })
    .call(axis); // <-I

注意

拥有一个 g 元素来包含与一个轴相关的所有 SVG 元素不仅是一种良好的实践,也是 D3 轴组件的要求。

此代码片段中的大部分逻辑都与使用 transform 属性在 svg 画布上计算绘制轴的位置相关(见行 H)。在前面的代码示例中,为了使用偏移量移动轴,我们使用了 translate SVG 变换,这允许我们使用定义在 xy 坐标中的距离参数来移动元素。

注意

SVG 变换将在第七章(“形状塑造”)中详细讨论,或你可以参考以下 URL 获取有关此主题的更多信息:

www.w3.org/TR/SVG/coords.html#TranslationDefined

此代码中更相关的部分在行 I,其中使用了 d3.selection.call 函数,并将 axis 对象作为参数传递。d3.selection.call 函数使用当前选择作为参数调用给定的函数(在我们的情况下是 axis 对象)。换句话说,传递给 d3.selection.call 函数的函数应具有以下形式:

function foo(selection) {
  ...
}

提示

d3.selection.call 函数还允许你向调用函数传递额外的参数。更多信息请访问以下链接:

github.com/mbostock/d3/wiki/Selections#wiki-call

一旦调用 D3 轴组件,它将负责其余工作并自动创建轴所需的所有必要的 SVG 元素(见行 I)。例如,本食谱中所示示例的水平底部时间轴具有以下自动生成的复杂 SVG 结构,我们实际上不需要了解太多:

如何工作...

水平底部时间轴 SVG 结构

自定义刻度

我们已经在之前的食谱中看到了如何使用ticks函数。这是在 D3 轴上可以进行的最简单的刻度相关自定义。在本食谱中,我们将介绍一些最常见和有用的与刻度相关的自定义。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter5/ticks.html

如何操作...

在以下代码示例中,我们将自定义子刻度、填充和标签的格式。让我们先看看代码片段:

<script type="text/javascript">
    var height = 500, 
        width = 500, 
        margin = 25,
        axisWidth = width - 2 * margin;

    var svg = d3.select("body").append("svg")
            .attr("class", "axis")
            .attr("width", width)
            .attr("height", height);

var scale = d3.scale.linear()
        .domain([0, 100])
        .range([0, axisWidth]);

    var axis = d3.svg.axis()
            .scale(scale)
            .ticks(5)
            .tickSubdivide(5) // <-A
 .tickPadding(10) // <-B
 .tickFormat(function(v){ // <-C
 return v + "%";
 });

    svg.append("g")        
        .attr("transform", function(){
            return "translate(" + margin + "," + margin + ")";
        })
        .call(axis);
</script>

上述代码生成以下视觉输出:

如何操作...

自定义轴刻度线

它是如何工作的...

本食谱的重点是ticks函数之后的突出显示的行。正如我们之前提到的,ticks函数为 D3 提供了一个提示,说明轴应该包含多少个刻度。在设置刻度数量之后,在本食谱中,我们继续通过进一步的函数调用来进一步自定义刻度。在行A中,使用了ticksSubdivide函数,以类似的方式为 D3 提供提示,说明轴应该在每个刻度之间渲染多少个子刻度。然后在行B中,使用了tickPadding函数来指定刻度标签和轴之间的空间(以像素为单位)。最后,在行C提供了一个自定义函数tickFormat,用于将百分号附加到值上。

注意

更多关于上述函数和其他与刻度相关的自定义信息,请访问以下 URL 的 D3 Wiki:

github.com/mbostock/d3/wiki/SVG-Axes#wiki-ticks

绘制网格线

很频繁地,我们需要在xy轴的刻度上绘制水平和垂直网格线,以保持与刻度的一致性。正如我们在前面的食谱中所示,通常我们不会或不想对 D3 轴上刻度的渲染方式有精确的控制。因此,在它们渲染之前,我们可能不知道有多少刻度和它们的值。这尤其适用于你正在构建一个可重用的可视化库,在这种情况下,你无法提前知道刻度配置。在本食谱中,我们将探讨一些在轴上绘制一致网格线的技术,而实际上并不需要知道刻度值。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter5/grid-line.html

如何操作...

首先,让我们看看如何在代码中绘制网格线:

<script type="text/javascript">
    var height = 500, 
        width = 500, 
        margin = 25;       

    var svg = d3.select("body").append("svg")
            .attr("class", "axis")
            .attr("width", width)
            .attr("height", height);

    function renderXAxis(){
        var axisLength = width - 2 * margin;

        var scale = d3.scale.linear()
                        .domain([0, 100])
                        .range([0, axisLength]);

        var xAxis = d3.svg.axis()
                .scale(scale)
                .orient("bottom");

        svg.append("g")       
            .attr("class", "x-axis")
 .attr("transform", function(){ // <-A
 return "translate(" + margin + "," + (height - margin) + ")";
 })
            .call(xAxis);

 d3.selectAll("g.x-axis g.tick") // <-B
 .append("line") // <-C
 .classed("grid-line", true)
 .attr("x1", 0) // <-D
 .attr("y1", 0)
 .attr("x2", 0)
 .attr("y2", - (height - 2 * margin));  // <-E
    }

    function renderYAxis(){        
        var axisLength = height - 2 * margin;

        var scale = d3.scale.linear()
                        .domain([100, 0])
                        .range([0, axisLength]);

        var yAxis = d3.svg.axis()
                .scale(scale)
                .orient("left");

        svg.append("g")       
            .attr("class", "y-axis")
 .attr("transform", function(){
 return "translate(" + margin + "," + margin + ")";
 })
            .call(yAxis);

 d3.selectAll("g.y-axis g.tick")
 .append("line")
 .classed("grid-line", true)
 .attr("x1", 0)
 .attr("y1", 0)
 .attr("x2", axisLength)
 .attr("y2", 0);
    }   

    renderYAxis();
    renderXAxis();
</script>

上述代码生成以下视觉输出:

如何操作...

轴线和网格线

它是如何工作的...

在这个示例中,renderXAxisrenderYAxis 函数分别创建了两个轴 xy。让我们看看 x 轴是如何渲染的。

一旦我们了解了如何渲染 x 轴及其网格线,由于它们几乎相同,渲染 y 轴的逻辑也容易理解。x 轴及其刻度没有复杂的定义,正如我们在本章中已经多次演示的那样。创建了一个 svg:g 元素来包含 x 轴结构。这个 svg:g 元素使用平移变换放置在图表的底部,如线 A 所示:

.attr("transform", function(){ // <-A
  return "translate(" + margin + "," + (height - margin) + ")";
})

重要的是要记住,当涉及到其任何子元素时,平移变换会改变坐标的参考框架。例如,在这个 svg:g 元素内部,如果我们创建一个坐标设置为 (0, 0) 的点,那么当我们在这个 SVG 画布上绘制这个点时,它实际上会被放置在 (margin, height – margin)。这是因为 svg:g 元素内的所有子元素都会自动转换到这个基准坐标,因此,参考框架发生了偏移。有了这个理解,让我们看看在轴渲染后如何生成动态网格线:

d3.selectAll("g.x-axis g.tick") // <-B
            .append("line") // <-C
                .classed("grid-line", true)
                .attr("x1", 0) // <-D
                .attr("y1", 0)
                .attr("x2", 0)
                .attr("y2", - (height - 2 * margin));  // <-E

轴渲染完成后,我们可以通过选择 g.tick 来选择轴上的所有刻度元素,因为每个刻度都由其自己的 svg:g 元素分组(见线 B)。然后在线 C 上,我们为每个 svg:g 刻度元素附加一个新的 svg:line 元素。SVG 线元素是 SVG 标准提供的最简单形状。它有四个主要属性:

  • x1y1 属性定义了这条线的起点

  • x2y2 属性定义了目标点

在我们的例子中,我们简单地设置了 x1、y1 和 x2 为 0,因为每个 g.tick 元素已经转换到了轴上的位置,因此,我们只需要更改 y2 属性来绘制垂直网格线。y2 属性被设置为 –(height – 2 * margin)。坐标为负的原因是,正如前一段代码中提到的,整个 g.x-axis 已经向下移动到 (height – margin)。因此,在绝对坐标下 y2 = (height – margin) – (height – 2 * margin) = margin,这是我们想要从 x 轴绘制的垂直网格线的顶部。

提示

在 SVG 坐标中,(0, 0) 表示 SVG 画布的左上角。

这就是 SVG 结构中带有相关网格线的 x 轴的样子:

工作原理...

带有网格线的 x 轴 SVG 结构

如前一张截图所示,一个代表网格线的 svg:line 元素被添加到前面在本节中讨论的 "g.tick" svg:g 容器元素中。

Y轴的网格线使用相同的技巧生成;唯一的区别是,我们不是像对x轴那样设置网格线的y2属性,而是更改x2属性,因为线条是水平的(见第F行):

d3.selectAll("g.y-axis g.tick")
            .append("line")
                .classed("grid-line", true)
                .attr("x1", 0)
                .attr("y1", 0)
                .attr("x2", axisLength) // <-F
 .attr("y2", 0);

坐标轴的动态缩放

在某些情况下,当由用户交互或数据源变化触发时,坐标轴使用的比例可能会改变。例如,用户可能会更改可视化的时间范围。这种变化也需要通过调整坐标轴的比例来反映。在本食谱中,我们将探讨如何动态地实现这一点,同时重新绘制与每个刻度相关的网格线。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter5/rescaling.html

如何实现...

下面是执行动态缩放的代码示例:

<script type="text/javascript">
    var height = 500, 
        width = 500, 
        margin = 25,
        xAxis, yAxis, xAxisLength, yAxisLength;

    var svg = d3.select("body").append("svg")     
            .attr("class", "axis")    
            .attr("width", width)
            .attr("height", height);

    function renderXAxis(){
        xAxisLength = width - 2 * margin;

        var scale = d3.scale.linear()
                        .domain([0, 100])
                        .range([0, xAxisLength]);

        xAxis = d3.svg.axis()
                .scale(scale)
                .tickSubdivide(1)
                .orient("bottom");

        svg.append("g")       
            .attr("class", "x-axis")
            .attr("transform", function(){ 
                return "translate(" + margin + "," 
                           + (height - margin) + ")";
            })
            .call(xAxis);
    }

    function rescale(){ // <-A
 var max = Math.round(Math.random() * 100);

 xAxis.scale().domain([0, max]); // <-B
        svg.select("g.x-axis")
            .transition()
 .call(xAxis); // <-C

        renderXGridlines();
    }       

    function renderXGridlines(){
        var lines = d3.selectAll("g.x-axis g.tick")
                .select("line.grid-line")
 .remove(); // <-D

        lines = d3.selectAll("g.x-axis g.tick")
                .append("line") 
                .classed("grid-line", true)

        lines.attr("x1", 0) 
                .attr("y1", 0)
                .attr("x2", 0)
                .attr("y2", - yAxisLength); 
    }

 renderXAxis();
 renderXGridlines();
</script>

以下代码生成了以下效果:

如何实现...

动态坐标轴缩放

注意

由于本书的范围有限,本食谱中的代码示例省略了与Y轴相关的代码。请参阅在线可用的完整代码示例。

它是如何工作的...

一旦您在屏幕上点击ReScale按钮,您将注意到坐标轴会缩放,同时所有刻度以及网格线都会随着平滑的过渡效果重新绘制。在本节中,我们将重点关注缩放的工作原理,并将过渡效果留到下一章Transition with Style中。本食谱中的大部分繁重工作都是由定义在第A行的rescale函数完成的。

function rescale(){ // <-A
  var max = Math.round(Math.random() * 100);

  xAxis.scale().domain([0, max]); // <-B
  svg.select("g.x-axis")
    .transition()
    .call(xAxis); // <-C

  renderXGridlines();
}   

要缩放一个轴,我们只需更改其域(见第B行)。如果您还记得,比例域表示数据域,而其范围对应于视觉域。因此,视觉范围应保持不变,因为我们没有调整 SVG 画布的大小。更新后,我们再次调用xAxis,传入svg:g元素作为x轴(见第C行);这个简单的调用将处理轴的更新,因此,我们的工作就完成了。在下一步中,我们还需要更新和重新绘制所有网格线,因为域的变化也会改变所有刻度:

function renderXGridlines(){
        var lines = d3.selectAll("g.x-axis g.tick")
                .select("line.grid-line")
                .remove(); // <-D

        lines = d3.selectAll("g.x-axis g.tick")
                .append("line") 
                .classed("grid-line", true)

        lines.attr("x1", 0) 
                .attr("y1", 0)
                .attr("x2", 0)
                .attr("y2", - yAxisLength); 
}

这是通过调用remove()函数移除每条网格线来实现的,如第D行所示,然后为缩放后的轴上的所有新刻度重新创建网格线。这种方法有效地使所有网格线在缩放过程中与刻度保持一致。

第六章. 风格化的过渡

在本章中,我们将涵盖:

  • 动画单个元素

  • 动画多个元素

  • 使用缓动函数

  • 使用缓动

  • 使用过渡链

  • 使用过渡过滤器

  • 听取过渡事件

  • 实现自定义插值器

  • 使用计时器

简介

"一张图片胜过千言万语。"

这句古老的智慧可以说是数据可视化最重要的基石之一。另一方面,动画是通过一系列快速连续的静态图像生成的。人类的眼睛和大脑通过正后像、Phi 现象和 beta 运动,能够创造出连续图像的错觉。正如 Rick Parent 在其杰出的作品《计算机动画算法和技术》中完美地表达的那样:

图片可以迅速传达大量信息,因为人类的视觉系统是一个复杂的信息处理器。因此,动态图像在短时间内传达更多信息具有潜力。事实上,人类的视觉系统已经进化,以适应不断变化的世界;它被设计用来注意和解释运动。

  • 父亲 R. 2012

这确实是数据可视化项目中使用动画的主要目标。在本章中,我们将重点关注 D3 过渡 的机制,涵盖从基础知识到更高级的主题,例如自定义插值和基于计时器的过渡。掌握过渡不仅将为你的可视化增添许多亮点,还将为你提供一套强大的工具集,用于可视化那些难以可视化的属性,如趋势和差异。

什么是过渡?

D3 过渡提供了在网页上使用 HTML 和 SVG 元素创建计算机动画的能力。D3 过渡实现了一种称为 基于插值的动画 的动画。计算机特别擅长值插值,因此,大多数计算机动画都是基于插值的。正如其名称所暗示的,这种动画能力的基础是值插值。

如果您还记得,我们已经在第四章“平衡技巧”中详细介绍了 D3 插值器和插值函数。过渡建立在插值和缩放之上,以提供随时间改变值的能力,从而产生动画。每个过渡都可以使用起始值和结束值(在动画中也称为关键帧)来定义,而不同的算法和插值器将逐帧填充中间值(也称为“中间插值”或简称“tweening”)。乍一看,如果您不熟悉动画算法和技术,这似乎是一种控制动画的较为不严谨的方法。然而,在现实中恰恰相反;基于插值的过渡可以提供对产生的运动直到每一帧的直接和具体期望,从而以简单的方式为动画师提供极大的控制。事实上,D3 过渡 API 设计得如此之好,以至于在大多数情况下,只需要几行代码就足以在数据可视化项目中实现所需的动画。现在,让我们动手尝试一些过渡,以进一步加深我们对这个主题的理解。

单元素动画

在这个菜谱中,我们将首先查看过渡的最简单情况——在单个元素上随时间插值属性以产生简单的动画。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter6/single-element-transition.html

如何做...

执行此简单过渡所需的代码非常简短;这对于任何动画师来说都是好消息:

<script type="text/javascript">
    var body = d3.select("body"), 
        duration = 5000;

    body.append("div") // <-A
            .classed("box", true)
            .style("background-color", "#e9967a") // <-B
        .transition() // <-C
 .duration(duration) // <-D
            .style("background-color", "#add8e6") // <-E
            .style("margin-left", "600px") // <-F
            .style("width", "100px")
            .style("height", "100px");
</script> 

此代码生成一个移动、缩小和颜色变化的正方形,如下截图所示:

如何做...

单元素过渡

它是如何工作的...

您可能会惊讶地发现,我们添加以启用此动画的额外代码仅在第C行和D行:

    body.append("div") // <-A
            .classed("box", true)
            .style("background-color", "#e9967a") // <-B
            .transition() // <-C
 .duration(duration) // <-D

首先,在第C行,我们调用d3.selection.transition函数来定义一个过渡。然后,transition函数返回一个过渡绑定的选择,它仍然代表当前选择中的相同元素。但现在,它配备了额外的功能,并允许进一步自定义过渡行为。第C行返回了第A行创建的div元素的过渡绑定选择。

在第 D 行,我们使用 duration() 函数将过渡的持续时间设置为 5000 毫秒。此函数还返回当前过渡选择,从而允许函数链式调用。正如我们在本章开头提到的,基于插值的动画通常只需要指定起始值和结束值,而让插值器和算法在一段时间内填充中间值。D3 过渡将调用 transition 函数之前设置的值视为起始值,将调用 transition 函数之后设置的值视为结束值。因此,在我们的例子中:

.style("background-color", "#e9967a") // <-B

在第 B 行定义的 background-color 样式被视为过渡的起始值。以下行中设置的样式都被视为结束值:

.style("background-color", "#add8e6") // <-E
.style("margin-left", "600px") // <-F
.style("width", "100px")
.style("height", "100px");

在这一点上,你可能想知道,为什么这些起始值和结束值不对称?。D3 过渡不需要每个插值值都有显式的起始值和结束值。如果缺少起始值,它将尝试使用计算后的样式;如果缺少结束值,则该值将被视为常量。一旦过渡开始,D3 将自动为每个值选择最合适的已注册插值器。在我们的例子中,第 E 行将使用 RGB 颜色插值器,而其余的样式值将使用字符串插值器——该插值器内部使用数字插值器来插值嵌入的数字。以下是我们将列出带有起始值和结束值的插值样式值:

  • background-color: 起始值 #e9967a 大于结束值 #add8e6

  • margin-left: 起始值是一个计算后的样式,并且大于结束值 600px

  • width: 起始值是一个计算后的样式,并且大于结束值 100px

  • height: 起始值是一个计算后的样式,并且大于结束值 100px

动画多个元素

一个更详细的数据可视化需要动画多个元素而不是单个元素,如前一个食谱所示。更重要的是,这些过渡通常需要由数据驱动,并与同一可视化中的其他元素协调。在本食谱中,我们将看到如何创建一个数据驱动的多元素过渡来生成移动条形图。随着时间的推移,新的条形被添加,图表从右向左平滑过渡。

准备工作

在您的网页浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter6/multi-element-transition.html

如何做到这一点...

如预期的那样,这个食谱比之前的稍微大一些,但并不是很大。让我们看看代码:

<script type="text/javascript">
    var id= 0, 
        data = [], 
        duration = 500, 
        chartHeight = 100, 
        chartWidth = 680;

for(var i = 0; i < 20; i++){ 
    push(data);   
}

    function render(data) {
        var selection = d3.select("body")
                .selectAll("div.v-bar")
 .data(data, function(d){return d.id;}); // <-A

        // enter
        selection.enter()
                .append("div")
                    .attr("class", "v-bar")
                    .style("position", "fixed")
 .style("top", chartHeight + "px")
 .style("left", function(d, i){
 return barLeft(i+1) + "px"; // <-B
 })
 .style("height", "0px") // <-C
                .append("span");

        // update
        selection
 .transition().duration(duration) // <-D
 .style("top", function (d) { 
 return chartHeight - barHeight(d) + "px"; 
 })
 .style("left", function(d, i){
 return barLeft(i) + "px";
 })
 .style("height", function (d) { 
 return barHeight(d) + "px"; 
 })
                .select("span")
                    .text(function (d) {return d.value;});

        // exit
        selection.exit()
 .transition().duration(duration) // <-E
 .style("left", function(d, i){
 return barLeft(-1) + "px"; //<-F
 })
 .remove(); // <-G
    }

 function push(data) {
 data.push({
 id: ++id, 
 value: Math.round(Math.random() * chartHeight)
 });
 }

function barLeft(i) {
 // start bar position is i * (barWidth + gap)
 return i * (30 + 2);
 }

 function barHeight(d) {
 return d.value;
 }

    setInterval(function () {
        data.shift();
        push(data);
        render(data);
    }, 2000);

    render(data);

    d3.select("body")
       .append("div")
           .attr("class", "baseline")
           .style("position", "fixed")
           .style("top", chartHeight + "px")
           .style("left", "0px")
           .style("width", chartWidth + "px");
</script>

以下代码在您的网页浏览器中生成一个滑动条形图,如下截图所示:

如何做到这一点...

滑动条形图

它是如何工作的...

表面上看,这个例子似乎相当复杂,效果复杂。每秒钟都需要创建一个新的条形并对其进行动画处理,同时其余的条形需要精确滑动。D3 集合导向函数的美丽之处在于,无论你操作多少元素,它的工作方式都是完全相同的;因此,一旦你理解了机制,你就会发现这个配方与之前的配方并没有太大的不同。

第一步,我们在行A上创建了一组垂直条形的数据绑定选择,然后可以使用经典的进入-更新-退出 D3 模式:

var selection = d3.select("body")
                .selectAll("div.v-bar")
                .data(data, function(d){return d.id;}); // <-A

我们尚未涉及的是d3.selection.data函数的第二个参数。在这里,我们知道这个函数被称为对象身份函数。使用此函数的目的是提供对象恒常性。简单来说,我们希望数据与视觉元素之间的绑定是稳定的。为了实现对象恒常性,每个数据项都需要有一个唯一的标识符。一旦提供了 ID,D3 将确保如果div元素绑定到{id: 3, value: 45},那么在更新选择计算时,相同的div元素将被用于具有相同id的数据项,尽管这次值可能已更改,例如{id: 3, value: 12}。对象恒常性在这个配方中至关重要;没有对象恒常性,滑动效果将无法工作。

备注

如果你想了解更多关于对象恒常性的信息,请查看以下链接中 Mike Bostock 的这篇优秀文章,他是 D3 的创造者:

备注

bost.ocks.org/mike/constancy/

第二步是使用d3.selection.enter函数创建这些垂直条形,并根据索引号计算每个条形的left位置(见行B):

        // enter
        selection.enter()
                .append("div")
                    .attr("class", "v-bar")
                    .style("position", "fixed")
                    .style("top", chartHeight + "px")
                    .style("left", function(d, i){
                        return barLeft(i+1) + "px"; // <-B
                    })
                    .style("height", "0px") // <-C
                .append("span");

值得注意的是,在enter部分,我们尚未调用过渡,这意味着我们在这里指定的任何值都将用作过渡的起始值。如果你注意到行C,条形高度被设置为0px。这使条形从零高度增长到目标高度的动画成为可能。同时,相同的逻辑也应用于条形的left位置(见行B),并被设置为barLeft(i+1),从而实现了我们想要的滑动过渡。

        // update
        selection
            .transition().duration(duration) // <-D
                .style("top", function (d) { 
                    return chartHeight - barHeight(d) + "px"; 
                })
                .style("left", function(d, i){
                    return barLeft(i) + "px";
                })
                .style("height", function (d) { 
                    return barHeight(d) + "px"; 
                })
                .select("span")
                    .text(function (d) {return d.value;});

完成进入(enter)部分后,我们现在可以处理更新(update)部分,其中定义了过渡。首先,我们希望为所有更新引入过渡,因此,在应用任何样式更改之前,我们调用transition函数(见行D)。一旦创建了过渡绑定的选择,我们应用以下样式过渡:

  • "top": chartHeight + "px" > chartHeight - barHeight(d)+"px"

  • "left": barLeft(i+1) + "px" > barLeft(i) + "px"

  • "height": "0px" > barHeight(d) + "px"

上述三种样式过渡就是处理新条形图以及每个现有条形图及其滑动效果所需的所有操作。最后,我们需要处理的最后一个情况是exit情况,当一个条形图不再需要时。因此,我们希望页面上条形图的数量保持不变。这在exit部分进行处理:

        // exit
        selection.exit()
                .transition().duration(duration) // <-E
                .style("left", function(d, i){
                    return barLeft(-1) + "px"; // <-F
                })
                .remove(); // <-G

到目前为止,在本章之前,我们一直是在调用d3.selection.exit函数后立即调用remove()函数。这立即移除了不再需要的元素。实际上,exit()函数也返回一个选择集,因此可以在调用remove()函数之前进行动画处理。这正是我们在这里所做的事情,使用exit选择集在行E上开始过渡;然后我们使用以下过渡更改来动画化左值:

  • left: barLeft(i) + "px" > barLeft(i-1) + "px"

由于我们总是移除最左边的条形图,这个过渡将条形图向左移动并移出 SVG 画布,然后将其删除。

注意

exit过渡不一定局限于简单的过渡,就像我们在本食谱中展示的那样。在某些可视化中,它可能像update过渡一样复杂。

一旦render函数就绪并定义了过渡,剩下的就是简单地更新数据,并使用setInterval函数每秒重新渲染我们的条形图。现在这个例子就完成了。

使用缓动

过渡可以被视为时间的函数。它是一个将时间进程映射到数值进程的函数,然后导致对象运动(如果数值用于定位)或变形(如果数值用于描述其他视觉属性)。时间总是以恒定的速度前进;换句话说,时间进程是均匀的(当然,除非你在黑洞附近进行可视化),然而,结果数值进程不需要是均匀的。缓动是提供这种映射灵活性和控制的标准技术。当一个过渡生成均匀的数值进程时,它被称为线性****缓动。D3 提供了对不同类型缓动功能的支持,在本例中,我们将探索不同的内置 D3 缓动函数,以及如何使用 D3 过渡实现自定义缓动函数。

准备工作

在您的网页浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter6/easing.html

如何做...

在以下代码示例中,我们将演示如何基于元素逐个自定义过渡缓动:

<script type="text/javascript">
 var data = [ // <-A
 "linear", "cubic", "cubic-in-out", 
 "sin", "sin-out", "exp", "circle", "back", 
 "bounce",
 function(t){ // <-B
 return t * t;
 }
 ],
        colors = d3.scale.category10();

    d3.select("body").selectAll("div")
 .data(data) // <-C
        .enter()
        .append("div")
            .attr("class", "fixed-cell")
            .style("top", function (d, i) {
                return i * 40 + "px";
            })
            .style("background-color", function (d, i) {
                return colors(i);
            })
            .style("color", "white")
            .style("left", "500px")
            .text(function (d) {
                if(typeof d === 'function') return "custom";
                return d;
            });

 d3.selectAll("div").each(function(d){
 d3.select(this)
 .transition().ease(d) // <-D
 .duration(1500)
 .style("left", "10px");
 });
</script>

前面的代码生成了一组具有不同缓动效果的移动框。以下截图是在缓动效果发生时的截图:

如何做...

不同的缓动效果

它是如何工作的...

在这个菜谱中,我们展示了多个不同的内置 D3 缓动函数及其对过渡的影响。让我们看看它是如何实现的。首先,我们创建了一个数组来存储我们想要展示的不同缓动模式:

var data = [ // <-A
        "linear", 
          "cubic", 
          "cubic-in-out", 
        "sin", 
          "exp", 
          "circle", 
          "back", 
        "bounce",
        function(t){ // <-B
            return t * t;
        }
        ]

虽然所有内置的缓动函数都简单地使用它们的名称定义,但这个数组的最后一个元素是一个自定义的缓动函数(二次缓动)。然后,使用这个数据数组创建了一组 div 元素,并为每个 div 元素创建了一个具有不同缓动函数的过渡,将它们从 ("left", "500px") 移动到 ("left", "10px")

d3.selectAll("div").each(function(d){
        d3.select(this)
            .transition().ease(d) // <-D
            .duration(1500)
            .style("left", "10px");
    });

到目前为止,你可能想知道,为什么我们没有像通常为任何其他 D3 属性所做的那样使用函数来指定缓动?

    .transition().ease(function(d){return d;}) // does not work
    .duration(1500)
    .style("left", "10px");

原因是它不适用于 ease() 函数。我们在行 D 上展示的是这个限制的解决方案,尽管在实际项目中,你很少需要按元素基础自定义缓动行为。

注意,无法按元素或属性自定义缓动函数;

D3 Wiki (2013 年 8 月)

提示

另一种克服这种限制的方法是使用自定义缓动,我们将在下一个菜谱中介绍。

如在行 D 所见,为 D3 过渡指定不同的缓动函数非常直接;你所需要做的就是在一个过渡绑定的选择上调用 ease() 函数。如果传入的参数是一个字符串,那么 D3 将尝试使用该名称查找匹配的函数;如果没有找到,则默认为 linear。除了命名的内置缓动函数之外,D3 还提供了缓动模式修饰符,你可以将其与任何缓动函数结合使用以实现额外的效果,例如,sin-outquad-out-in。可用的缓动模式修饰符:

  • in: 默认

  • out: 反转

  • in-out: 反射

  • out-in: 反转并反射

提示

D3 使用的默认缓动效果是 cubic-in-out

关于支持的 D3 缓动函数列表,请参阅以下链接:

github.com/mbostock/d3/wiki/Transitions#wiki-d3_ease

当使用自定义缓动函数时,该函数应接受当前参数时间值作为其参数,范围在 [0, 1] 之间。

function(t){ // <-B
  return t * t;
}

在我们的例子中,我们实现了一个简单的二次缓动函数,这实际上是一个内置的 D3 缓动函数,命名为 quad

注意

关于缓动和彭纳方程(包括 D3 和 jQuery 在内的大多数现代 JavaScript 框架实现)的更多信息,请查看以下链接:

www.robertpenner.com/easing/

使用缓动

Tween 来自单词 "inbetween",这是传统动画中的一种常见做法,在关键帧由主动画师创建后,经验较少的动画师被用来生成关键帧之间的帧。这个短语被借用到现代计算机生成的动画中,它指的是控制“inbetween”帧如何生成的技术或算法。在本食谱中,我们将检查 D3 过渡如何支持 tweening。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter6/tweening.html

如何做...

在以下代码示例中,我们将创建一个自定义的 tweening 函数来通过九个离散整数来动画化按钮标签:

<script type="text/javascript">
    var body = d3.select("body"), duration = 5000;

    body.append("div").append("input")
        .attr("type", "button")
        .attr("class", "countdown")
        .attr("value", "0")
        .style("width", "150px")
        .transition().duration(duration).ease("linear")
            .style("width", "400px")
            .attr("value", "9");

    body.append("div").append("input")
        .attr("type", "button")
        .attr("class", "countdown")
        .attr("value", "0")
 .transition().duration(duration).ease("linear")
 .styleTween("width", widthTween) // <- A
 .attrTween("value", valueTween); // <- B

 function widthTween(a){
 var interpolate = d3.scale.quantize()
 .domain([0, 1])
 .range([150, 200, 250, 350, 400]);

 return function(t){
 return interpolate(t) + "px";
 };
 }

 function valueTween(){
 var interpolate = d3.scale.quantize() // <-C
 .domain([0, 1])
 .range([1, 2, 3, 4, 5, 6, 7, 8, 9]);

 return function(t){ // <-D
 return interpolate(t);
 };
    }        
</script>

以下代码生成了两个以非常不同的速率变形的按钮,以下截图是在此过程进行时的截图:

如何做...

Tweening

它是如何工作的...

在这个食谱中,第一个按钮是使用简单的带有线性缓动的过渡创建的:

body.append("div").append("input")
        .attr("type", "button")
        .attr("class", "countdown")
        .attr("value", "0")
        .style("width", "150px")
        .transition().duration(duration).ease("linear")
            .style("width", "400px")
            .attr("value", "9");

过渡将按钮的宽度从 "150px" 变更到 "400px",同时将其值从 "0" 变更到 "9"。正如预期的那样,这个过渡仅仅依赖于使用 D3 字符串插值器对这些值的连续线性插值。相比之下,第二个按钮的效果是分块地改变这些值。从 1 变到 2,然后到 3,依此类推,直到 9。这是通过使用 D3 的attrTweenstyleTween函数的 tweening 支持来实现的。让我们首先看看按钮值 tweening 是如何工作的:

.transition().duration(duration).ease("linear")
            .styleTween("width", widthTween) // <- A
            .attrTween("value", valueTween); // <- B

在前面的代码片段中,我们可以看到,与我们在第一个按钮的情况下设置值属性的结束值不同,我们使用attrTween函数并提供了一个 tweening 函数valueTween,其实现如下:

function valueTween(){
    var interpolate = d3.scale.quantize() // <-C
        .domain([0, 1])
        .range([1, 2, 3, 4, 5, 6, 7, 8, 9]);

    return function(t){ // <-D
        return interpolate(t);
    };
}        

在 D3 中,一个 tween 函数预期是一个工厂函数,它构建用于执行 tweening 的实际函数。在这种情况下,我们定义了一个quantize比例尺,它将域[0, 1]映射到离散的整数范围[1, 9],在行C上。实际在行D上定义的 tweening 函数简单地使用量化比例尺插值参数时间值,从而生成跳跃整数效果。

注意

离散比例尺是线性比例尺的一种变体,它有一个离散的范围而不是连续的范围。有关离散比例尺的更多信息,请访问以下链接:

github.com/mbostock/d3/wiki/Quantitative-Scales#wiki-quantize

还有更多...

到目前为止,我们已经触及了与过渡相关的三个概念:缓动、tween 和插值。通常,D3 过渡是通过以下序列图中的三个级别定义和驱动的:

还有更多...

过渡的驱动因素

如我们通过多个配方所展示的,D3 过渡支持在三个级别上进行自定义。这给了我们极大的灵活性,可以精确地自定义过渡行为。

小贴士

虽然自定义缓动通常使用插值来实现,但你在自己的缓动函数中可以做到的事情没有限制。完全有可能在不使用 D3 插值器的情况下生成自定义缓动。

在这个配方中,我们使用了线性缓动来突出缓动效果,然而,D3 完全支持 缓动缓动,这意味着你可以将我们在上一个配方中展示的任何缓动函数与你的自定义缓动结合,以生成更复杂的过渡效果。

使用过渡链式连接

本章的前四个配方专注于 D3 中的单个过渡控制,包括自定义缓动和缓动函数。然而,有时无论你进行多少缓动或缓动,单个过渡都远远不够,例如,你想要通过首先将 div 元素挤压成光束,然后将光束传递到网页上的不同位置,最后将 div 恢复到原始大小来模拟传送 div 元素。在这个配方中,我们将看到如何使用 过渡链式连接 来实现这种类型的过渡。

准备工作

在你的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter6/chaining.html

如何实现...

我们简单的传送过渡代码出奇地短:

<script type="text/javascript">
    var body = d3.select("body");

 function teleport(s){
 s.transition().duration(300) // <-A
 .style("width", "200px")
 .style("height", "1px")
 .transition().duration(100) // <-B
 .style("left", "600px")
 .transition().duration(300) // <-C
 .style("left", "800px")
 .style("height", "80px")
 .style("width", "80px");
 }

    body.append("div")    
            .style("position", "fixed")
            .style("background-color", "steelblue")
            .style("left", "10px")
            .style("width", "80px")
            .style("height", "80px")
 .call(teleport); // <-D 
</script> 

上一段代码执行了一个 div 传送:

如何实现...

通过过渡链式连接实现 DIV 传送

工作原理...

这个简单的传送效果是通过链式连接几个过渡来实现的。在 D3 中,当过渡链式连接时,它们保证只有在之前的过渡达到完成状态后才会执行。现在,让我们看看代码中是如何实现的:

function teleport(s){
    s.transition().duration(300) // <-A
        .style("width", "200px")
        .style("height", "1px")
    .transition().duration(100) // <-B
        .style("left", "600px")
    .transition().duration(300) // <-C
        .style("left", "800px")
        .style("height", "80px")
        .style("width", "80px");
};

第一个过渡在行 A(压缩)上定义并启动,然后在行 B 上创建了一个第二个过渡(光束),最后第三个过渡在行 C(恢复)上链式连接。过渡链式连接是一种强大而简单的技术,通过将简单的过渡连接在一起来编排复杂的过渡效果。最后在这个配方中,我们还展示了通过将传送过渡包装在函数中,然后使用 d3.selection.call 函数在选择上应用它(见行 D)的基本示例,以实现可重用组合过渡效果。可重用过渡效果对于遵循 DRY(不要重复自己)原则至关重要,尤其是在你的可视化动画变得更加复杂时。

使用过渡过滤器

在某些情况下,您可能需要选择性地将过渡应用于某个选择的子集。在这个食谱中,我们将使用数据驱动过渡过滤技术来探索这种效果。

准备工作

在您的网页浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter6/filtering.html

如何实现...

在这个食谱中,我们将一组 div 元素(或方框)从网页的右侧移动到左侧。在将所有方框移动到左侧后,我们将仅选择性地将标记为 Cat 的方框移回,这样它们就不会相互争斗。让我们看看以下代码:

<script type="text/javascript">
    var data = ["Cat", "Dog", "Cat", "Dog", "Cat", "Dog", "Cat", "Dog"],
        duration = 1500;

    d3.select("body").selectAll("div")
            .data(data)
        .enter()
        .append("div")
            .attr("class", "fixed-cell")
            .style("top", function (d, i) {
                return i * 40 + "px";
            })
            .style("background-color", "steelblue")
            .style("color", "white")
            .style("left", "500px")
            .text(function (d) {
                return d;
            })
 .transition() // <- A
 .duration(duration)
 .style("left", "10px")
 .filter(function(d){return d == "Cat";}) // <- B
 .transition() // <- C
 .duration(duration)
 .style("left", "500px");
</script>

页面过渡后的样子如下所示:

如何实现...

过渡过滤

它是如何工作的...

这个食谱的初始设置相当简单,因为我们希望将管道保持尽可能少,这将有助于您专注于技术的核心。我们有一个包含交错字符串 "Cat""Dog" 的数据数组。然后为数据创建了一组 div 方框,并创建了一个过渡(见行 A),将所有方框移动到网页的左侧。到目前为止,这是一个多元素过渡的简单示例,还没有任何惊喜:

.transition() // <- A
.duration(duration)
    .style("left", "10px")
.filter(function(d){return d == "Cat";}) // <- B
.transition() // <- C
.duration(duration)
    .style("left", "500px");

然后在行 B,使用了 d3.selection.filter 函数来生成只包含 "cat" 方框的子选择。记住,D3 过渡仍然是一个选择(过渡绑定选择),因此,d3.selection.filter 函数在常规选择上的工作方式完全相同。一旦通过 filter 函数生成了子选择,我们就可以单独对此子选择应用一个二级过渡(见行 C)。filter 函数返回一个过渡绑定的子选择;因此,行 C 上创建的第二个过渡实际上是在生成一个过渡链。它将在第一个过渡完成之后才会被触发。通过使用过渡链和过滤的组合,我们可以生成一些真正有趣的数据驱动动画;这是任何数据可视化工具集中的一个有用工具。

参见

监听过渡事件

过渡链允许在初始过渡达到完成状态后触发二级过渡;然而,有时您可能需要触发除过渡之外的其他某些操作,或者可能在过渡期间做其他事情。这就是过渡事件监听器设计的目的,它们是本食谱的主题。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter6/events.html

如何做到这一点...

在这个配方中,我们将演示如何根据动画 div 元素的转换状态显示不同的标题。显然,这个例子可以很容易地扩展以使用相同的技术执行更有意义的任务:

<script type="text/javascript">
    var body = d3.select("body"), duration = 3000;

    var div = body.append("div")
            .classed("box", true)
            .style("background-color", "steelblue")
            .style("color", "white")
 .text("waiting") // <-A
 .transition().duration(duration) // <-B
 .delay(1000) // <-C
 .each("start", function(){ // <-D
 console.log(arguments);
 d3.select(this).text(function (d, i) {
 return "transitioning";
 });
 })
 .each("end", function(){ // <-E
 d3.select(this).text(function (d, i) {
 return "done";
 });
 })
            .style("margin-left", "600px");
</script>

上述代码产生以下视觉输出,其中出现一个带有等待标签的框;它向右移动,标签变为转换中,完成后停止移动并将标签改为完成

如何做到这一点...

转换事件处理

它是如何工作的...

在这个配方中,我们构建了一个具有简单水平移动转换的单个 div 元素,当它被启动时,也会根据其转换状态更改标签。让我们首先看看我们是如何显示等待标签的:

var div = body.append("div")
            .classed("box", true)
            .style("background-color", "steelblue")
            .style("color", "white")
            .text("waiting") // <-A
        .transition().duration(duration) // <-B
                .delay(1000) // <-C

在定义在行 B 上的转换之前,在行 A 上设置了等待标签,然而,我们也为转换指定了延迟,因此在转换开始之前显示了等待标签。接下来,让我们看看我们是如何在转换期间显示转换中标签的:

.each("start", function(){ // <-D
    d3.select(this).text(function (d, i) {
        return "transitioning";
    });
})

这是通过调用 each() 函数并选择其第一个参数设置为 "start" 事件名称,并将事件监听器函数作为第二个参数传递来实现的。事件监听器函数的 this 引用指向当前选定的元素,因此可以被 D3 包装并进行进一步操作。转换 "end" 事件以相同的方式处理:

.each("end", function(){ // <-E
    d3.select(this).text(function (d, i) {
        return "done";
    });
})

这里的唯一区别是事件名称被传递到 each() 函数中。

实现自定义插值

在第四章 Tipping the ScalesTipping the Scales 中,我们探讨了如何在 D3 中实现自定义插值器。在这个配方中,我们将演示如何将这种技术与 D3 转换结合使用,通过利用自定义插值生成特殊的转换效果。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter6/custom-interpolator-transition.html

这个配方建立在我们在第四章 Tipping the Scales 中讨论的 Implementing a custom interpolator 配方之上,Tipping the Scales。如果您不熟悉自定义插值的概念,请在继续此配方之前先查看相关配方。

如何做到这一点...

让我们看看 custom-interpolator-transition.html 文件的代码,看看它是如何工作的:

 <script type="text/javascript">
 d3.interpolators.push(function(a, b) { // <-A
 var re = /^([a-z])$/, ma, mb;
 if ((ma = re.exec(a)) && (mb = re.exec(b))) {
 a = a.charCodeAt(0);
 var delta = a - b.charCodeAt(0);
 return function(t) {
 return String.fromCharCode(Math.ceil(a - delta * t));
 };
 }
 });

    var body = d3.select("body");

    var countdown = body.append("div").append("input");

    countdown.attr("type", "button")
        .attr("class", "countdown")
 .attr("value", "a") // <-B
 .transition().ease("linear") // <-C
 .duration(4000).delay(300)
 .attr("value", "z"); // <-D
</script>

上述代码生成一个从 a 开始并结束于 z 的跳动框:

如何实现...

使用自定义插值进行过渡

它是如何工作的...

在这个菜谱中,我们首先注册了一个自定义插值器,它与我们在第四章(第四章. 调整比例)中讨论的字母插值器相同:调整比例

d3.interpolators.push(function(a, b) { // <-A
      var re = /^([a-z])$/, ma, mb;
      if ((ma = re.exec(a)) && (mb = re.exec(b))) {
        a = a.charCodeAt(0);
        var delta = a - b.charCodeAt(0);
        return function(t) {
          return String.fromCharCode(Math.ceil(a - delta * t));
        };
      }
});

一旦注册了自定义插值器,过渡部分几乎没有任何自定义逻辑。因为它基于需要插值和过渡的值,D3 会自动选择正确的插值器来完成这项任务:

countdown.attr("type", "button")
        .attr("class", "countdown")
        .attr("value", "a") // <-B
        .transition().ease("linear") // <-C
        .duration(4000).delay(300)
        .attr("value", "z"); // <-D

如前述代码片段所示,起始值是 "a",在行 B 中定义。之后,在行 C 上创建了一个标准的 D3 过渡,最后我们只需在行 D 上将结束值设置为 "z",然后 D3 和我们的自定义插值器就会处理剩下的部分。

使用计时器

到目前为止,在本章中我们已经讨论了 D3 过渡的各个方面。此时你可能会问,是什么在驱动 D3 过渡,从而生成动画帧?

在这个菜谱中,我们将探索一个低级的 D3 计时器函数,你可以利用它从头开始创建自己的自定义动画。

准备工作

在你的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter6/timer.html

如何实现...

在这个菜谱中,我们将创建一个不依赖于 D3 过渡或插值的自定义动画;本质上是从头开始创建的自定义动画。让我们看看以下代码:

<script type="text/javascript">
    var body = d3.select("body");

    var countdown = body.append("div").append("input");

    countdown.attr("type", "button")
        .attr("class", "countdown")
        .attr("value", "0");

    function countup(target){ // <-A
        d3.timer(function(){ // <-B
            var value = countdown.attr("value");
            if(value == target) return true;  // <-C
            countdown.attr("value", ++value); // <-D            
        });
    }

    function reset(){
        countdown.attr("value", 0);
    }
</script>

<div class="control-group">
    <button onclick="countup(100)">
        Start
    </button>
    <button onclick="reset()">
        Clear
    </button>
</div>

上述代码生成一个盒子,其中计时器被设置为 0,通过点击 开始,计时器增加到 100 并停止,如下所示:

如何实现...

基于计时器的自定义动画

它是如何工作的...

在这个例子中,我们构建了一个从 0 到 100 移动整数的自定义动画。对于这样简单的动画,当然我们可以使用 D3 过渡和缓动函数来完成。然而,这样的简单例子避免了任何对技术本身的干扰。此外,即使在这样简单的例子中,基于计时器的解决方案在可伸缩性和灵活性方面也优于典型的基于过渡的解决方案。这个动画的强大之处在于 countup 函数(见行 A):

function countup(target){ // <-A
        d3.timer(function(){ // <-B
            var value = countdown.attr("value");
            if(value == target) return true;  // <-C
            countdown.attr("value", ++value); // <-D            
        });
    }

如本例所示,理解这个菜谱的关键在于 d3.timer 函数。

这个 d3.timer(function, [delay], [mark]) 开始一个自定义计时器函数,并重复调用给定的函数,直到函数返回 true。一旦开始计时器,就无法停止它,因此程序员必须确保函数最终返回 true。可选地,你也可以指定一个 延迟 以及一个 标记。延迟从标记开始,如果没有指定标记,则使用 Date.now 作为标记。以下插图显示了我们所讨论的时间关系:

如何工作...

在我们的实现中,自定义的 timer 函数每次被调用时(见行 D)都会将按钮标题增加一,当值达到 100 时返回 true,因此计时器终止(见行 C)。

内部来说,D3 过渡使用相同的计时器函数来生成其动画。在这个时候,你可能想知道使用 d3.timer 和直接使用动画帧有什么区别。答案是,如果浏览器支持的话,d3.timer 实际上会使用动画帧,否则,它会足够智能地回退到使用 setTimeout 函数,从而让你不必担心浏览器的支持问题。

参见

第七章. 形状塑造

在本章中,我们将涵盖:

  • 创建简单形状

  • 使用线生成器

  • 使用线插值

  • 改变线张力

  • 使用区域生成器

  • 使用区域插值

  • 使用弧生成器

  • 实现弧过渡

简介

可缩放矢量图形SVG)是一个成熟的万维网联盟W3C)标准,旨在为 Web 和移动平台上的用户交互式图形设计。与 HTML 类似,SVG 可以与现代浏览器中的 CSS 和 JavaScript 等其他技术愉快地共存,形成许多 Web 应用程序的骨干。在今天的 Web 中,你可以看到 SVG 的用例无处不在,从数字地图到数据可视化。到目前为止,在这本书中,我们已经涵盖了使用 HTML 元素的大部分食谱,然而,在现实世界的项目中,SVG 是数据可视化的实际标准;它也是 D3 真正发光的地方。在本章中,我们将介绍 SVG 的基本概念以及 D3 对 SVG 形状生成的支持。SVG 是一个非常丰富的主题。可以,并且已经有许多书籍单独致力于这个主题,因此,我们并不打算或试图涵盖所有与 SVG 相关的主题,而是将重点放在 D3 和数据可视化相关的技术和功能上。

什么是 SVG?

如其名所示,SVG 是关于图形的。它是用可缩放向量描述图形图像的一种方式。让我们看看 SVG 的两个主要优势:

向量

SVG 图像基于向量而不是像素。基于像素的方法中,图像由一个位图组成,其坐标用颜色色素填充,具有xy。而基于向量的方法中,每个图像由一组使用简单和相对公式描述的几何形状组成,并填充了某种纹理。正如你可以想象的那样,这种方法自然适合数据可视化的需求。在 SVG 中使用线条、条形和圆形来可视化你的数据,比在位图中尝试操纵颜色色素要简单得多。

可扩展性

SVG 的第二个特性是可扩展性。由于 SVG 图形是由相对公式描述的一组几何形状,它可以以不同的尺寸和缩放级别进行渲染和重新渲染,而不会丢失精度。另一方面,当基于位图的图像被放大到高分辨率时,它们会遭受像素化的影响,这是当单个像素变得可见时发生的,而 SVG 没有这个缺点。请参见以下图表,以更好地了解我们刚才读到的内容:

可扩展性

SVG 与位图像素化对比

作为数据可视化者,使用 SVG 可以让你在任意分辨率上展示你的可视化,而不会失去你引人注目创作的清晰度。除此之外,SVG 还提供了一些额外的优势,例如:

  • 可读性:SVG 基于 XML,一种人类可读的标记语言

  • 开放标准:SVG 由 W3C 创建,不是专有供应商标准

  • 采用:所有现代浏览器都支持 SVG 标准,甚至在移动平台上也是如此

  • 互操作性:SVG 与其他网络技术(如 CSS 和 JavaScript)兼容良好;D3 本身就是这种能力的完美展示

  • 轻量级:与基于位图的图像相比,SVG 要轻得多,占用的空间小得多

由于我们之前提到的所有这些功能,SVG 已经成为网络数据可视化的事实标准。从本章开始,本书中的所有食谱都将使用 SVG 作为其最重要的部分进行说明,通过 SVG 可以展示 D3 的真正力量。

注意

一些较旧的浏览器不支持 SVG。如果您的目标用户正在使用旧版浏览器,请在决定 SVG 是否适合您的可视化项目之前检查 SVG 兼容性。以下是一个您可以访问的链接,用于检查您浏览器的兼容性:

caniuse.com/svg

创建简单形状

在本食谱中,我们将探索一些简单的内置 SVG 形状公式及其属性。这些简单形状很容易生成,通常在需要时手动使用 D3 创建。尽管这些简单形状不是与 D3 一起工作时最有用的形状生成器,但偶尔在可视化项目中绘制边缘形状时它们可能很有用。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter7/simple-shapes.html

如何操作...

在本食谱中,我们将使用原生的 SVG 形状元素以四种不同的颜色绘制四种不同的形状:

<script type="text/javascript">
    var width = 600,
        height = 500;

    var svg = d3.select("body").append("svg");

    svg.attr("height", height)
        .attr("width", width);    

 svg.append("line") // <-A
 .attr("x1", 0)
 .attr("y1", 200)
 .attr("x2", 100)
 .attr("y2", 100);

 svg.append("circle") // <-B
 .attr("cx", 200)
 .attr("cy", 150)
 .attr("r", 50);

 svg.append("rect")
 .attr("x", 300) // <-C
 .attr("y", 100)
 .attr("width", 100) // <-D
 .attr("height", 100)
 .attr("rx", 5); // <-E

 svg.append("polygon")
 .attr("points", "450,200 500,100 550,200"); // <-F
</script>

上述代码生成了以下视觉输出:

如何操作...

简单的 SVG 形状

工作原理...

在这个例子中,我们使用 SVG 内置形状元素绘制了四种不同的形状:一条线、一个圆、一个矩形和一个三角形。

SVG 坐标系简要回顾

SVG 的 xy 坐标系起源于画布的左上角 (0, 0),并延伸到右下角 (<width>, <height>)

  • line:一个线元素通过坐标属性 x1y1 作为起点,x2y2 作为终点创建一条简单的直线(见线 A)。

  • circleappend() 函数通过定义圆心的坐标属性 cxcy 以及定义圆的半径的属性 r 来绘制一个圆(见线 B)。

  • rect: append() 函数通过坐标属性 xy 绘制一个矩形,这些属性定义了矩形的左上角(见线 C),widthheight 属性用于控制矩形的大小,而 rxry 属性可以用来引入圆角。rxry 属性控制用于圆角椭圆的 xy 轴半径(见线 E)。

  • polygon: 要绘制多边形,需要使用 points 属性定义组成多边形的一组点(见线 F)。points 属性接受由空格分隔的点坐标列表:

    svg.append("polygon")
        .attr("points", "450,200 500,100 550,200"); // <-F
    

所有 SVG 形状都可以使用样式属性直接或通过 CSS(类似于 HTML 元素)进行样式化。此外,它们可以使用 SVG 变换和过滤支持进行变换和过滤,但由于本书的范围有限,我们不会详细讨论这些主题。在本章的其余部分,我们将专注于 D3 特定的 SVG 形状生成支持。

还有更多...

SVG 还支持 ellipsepolyline 元素,但由于它们与 circlepolygon 的相似性,我们在这本书中不会详细讨论它们。有关 SVG 形状元素的更多信息,请访问 www.w3.org/TR/SVG/shapes.html

D3 SVG 形状生成器

在 SVG 形状元素中,“瑞士军刀”般的存在是 svg:path。路径定义了任何形状的轮廓,然后可以被填充、描边或裁剪。到目前为止,我们讨论的所有形状都可以仅使用 svg:path 进行数学定义。SVG path 是一个非常强大的结构,拥有自己的迷你语言和语法。svg:path 的迷你语言用于设置 svg:path 元素上的 "d" 属性,该属性由以下命令组成:

  • moveto: 命令 M(绝对)/m(相对) 移动到 (x y)+

  • closepath: Z(绝对)/z(相对) 关闭路径

  • lineto: L(绝对)/l(相对) 直线到 (x y)+, H(绝对)/h(相对) 水平直线到 x+, V(绝对)/v(相对) 垂直直线到 y+

  • 三次贝塞尔曲线: C(绝对)/c(相对) 曲线到 (x1 y1 x2 y2 x y)+, S(绝对)/s(相对) 简写曲线到 (x2 y2 x y)+

  • 二次贝塞尔曲线: Q(绝对)/q(相对) 二次贝塞尔曲线到 (x1 y1 x y)+, T(绝对)/t(相对) 简写二次贝塞尔曲线到 (x y)+

  • 椭圆曲线: A(绝对)/a(相对) 椭圆弧 (rx ry x-axis-rotation large-arc-flag sweep-flag x y)+

由于直接使用路径语言晦涩难懂,因此,在大多数情况下,需要某种软件,例如 Adobe Illustrator 或 Inkscape,来帮助我们直观地创建 SVG path 元素。同样,D3 附带了一套 SVG 形状生成器函数,可以用来生成数据驱动的路径公式;这就是 D3 如何通过结合 SVG 的力量和直观的数据驱动方法,真正地革新了数据可视化领域。这也将是本章剩余部分的重点。

参考以下内容

使用线生成器

D3 线生成器可能是最通用的生成器之一。尽管它被称为“线”生成器,但它与 svg:line 元素关系不大。相反,它是使用 svg:path 元素实现的。像 svg:path 一样,D3 line 生成器非常灵活,你可以仅使用 line 有效地绘制任何形状。然而,为了使你的生活更轻松,D3 还提供了其他更专业的形状生成器,这些生成器将在本章后面的菜谱中介绍。在这个菜谱中,我们将使用 d3.svg.line 生成器绘制多条数据驱动的线。

准备工作

在你的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter7/line.html

如何做...

现在,让我们看看线生成器的实际应用:

<script type="text/javascript">
    var width = 500,
        height = 500,
        margin = 50,
 x = d3.scale.linear() // <-A
 .domain([0, 10])
 .range([margin, width - margin]),
 y = d3.scale.linear() // <-B
 .domain([0, 10])
 .range([height - margin, margin]);

 var data = [ // <-C
 [
 {x: 0, y: 5},{x: 1, y: 9},{x: 2, y: 7},
 {x: 3, y: 5},{x: 4, y: 3},{x: 6, y: 4},
 {x: 7, y: 2},{x: 8, y: 3},{x: 9, y: 2}
 ],

 d3.range(10).map(function(i){
 return {x: i, y: Math.sin(i) + 5};
 })
 ];

 var line = d3.svg.line() // <-D
 .x(function(d){return x(d.x);})
 .y(function(d){return y(d.y);});

    var svg = d3.select("body").append("svg");

    svg.attr("height", height)
        .attr("width", width);

     svg.selectAll("path.line")
            .data(data)
        .enter()
 .append("path") // <-E
            .attr("class", "line")            
 .attr("d", function(d){return line(d);}); // <-F

    // Axes related code omitted
    ...        
</script>

前面的代码在 xy 轴上绘制了多条线:

如何做...

D3 线生成器

工作原理...

在这个菜谱中,我们用来绘制线的数据定义在一个二维数组中:

var data = [ // <-C
        [
            {x: 0, y: 5},{x: 1, y: 9},{x: 2, y: 7},
            {x: 3, y: 5},{x: 4, y: 3},{x: 6, y: 4},
            {x: 7, y: 2},{x: 8, y: 3},{x: 9, y: 2}
        ],

        d3.range(10).map(function(i){
            return {x: i, y: Math.sin(i) + 5};
        })
];

第一个数据系列是手动和明确定义的,而第二个系列是使用数学公式生成的。这两种情况在数据可视化项目中都很常见。一旦定义了数据,为了将数据点映射到其视觉表示,就创建了两个刻度用于 xy 坐标:

x = d3.scale.linear() // <-A
            .domain([0, 10])
            .range([margin, width - margin]),
y = d3.scale.linear() // <-B
            .domain([0, 10])
            .range([height - margin, margin]);

注意,这些刻度的域被设置为足够大,以包含两个系列中的所有数据点,而范围被设置为表示画布区域,不包括边距。由于我们希望原点位于画布的左下角而不是 SVG 标准的左上角,因此 y 轴的范围是反转的。一旦设置了数据和刻度,我们只需要使用 d3.svg.line 函数生成线来定义我们的生成器:

var line = d3.svg.line() // <-D
            .x(function(d){return x(d.x);})
            .y(function(d){return y(d.y);});

d3.svg.line函数返回一个 D3 线生成器函数,您可以进一步自定义。在我们的例子中,我们只是为这个特定的线生成器声明了x坐标,它将使用x比例映射来计算,而y坐标将由y比例映射。使用 D3 比例来映射坐标不仅方便,而且是一种广泛接受的最佳实践(关注点分离)。尽管技术上您可以使用任何您喜欢的任何方法来实现这些函数。现在唯一剩下的事情就是实际创建svg:path元素。

svg.selectAll("path.line")
            .data(data)
        .enter()
            .append("path") // <-E
            .attr("class", "line")            
            .attr("d", function(d){return line(d);}); // <-F

路径创建过程非常直接。使用我们定义的数据数组创建了两个svg:path元素(在行E)。然后,使用我们之前创建的line生成器,通过传递数据d作为输入参数来设置每个路径元素的d属性。以下截图显示了生成的svg:path元素的外观:

它是如何工作的...

生成的 SVG 路径元素

最后,使用我们之前定义的相同的xy轴创建了两个轴。由于本书的范围有限,我们省略了本食谱和本章其余部分中与轴相关的代码,因为它们实际上没有变化,也不是本章的重点。

参见

  • 有关 D3 轴支持详细信息,请访问第五章, 玩转轴

使用线插值

默认情况下,D3 线生成器使用线性插值模式,但是 D3 支持多种不同的线插值模式。线插值确定数据点将以何种方式连接,例如,通过直线(线性插值)或曲线(三次插值)。在本食谱中,我们将向您展示如何设置这些插值模式及其效果。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter7/line-interpolation.html

这个食谱是在之前食谱的基础上构建的,所以,如果你还不熟悉基本的行生成器函数,请在继续之前先复习一下之前的食谱。

如何做...

现在,让我们看看如何使用不同的线插值模式:

    var width = 500,
        height = 500,
        margin = 30,
        x = d3.scale.linear()
            .domain([0, 10])
            .range([margin, width - margin]),
        y = d3.scale.linear()
            .domain([0, 10])
            .range([height - margin, margin]);

    var data = [
        [
            {x: 0, y: 5},{x: 1, y: 9},{x: 2, y: 7},
            {x: 3, y: 5},{x: 4, y: 3},{x: 6, y: 4},
            {x: 7, y: 2},{x: 8, y: 3},{x: 9, y: 2}
        ],  
        d3.range(10).map(function(i){
            return {x: i, y: Math.sin(i) + 5};
        })
    ];

    var svg = d3.select("body").append("svg");

    svg.attr("height", height)
        .attr("width", width);        

    renderAxes(svg);

    render("linear");    

    renderDots(svg);

    function render(mode){
        var line = d3.svg.line()
 .interpolate(mode) // <-A
                .x(function(d){return x(d.x);})
                .y(function(d){return y(d.y);});

        svg.selectAll("path.line")
                .data(data)
            .enter()
                .append("path")
                .attr("class", "line");                

        svg.selectAll("path.line")
                .data(data)       
            .attr("d", function(d){return line(d);});        
    }

 function renderDots(svg){ // <-B
 data.forEach(function(set){
 svg.append("g").selectAll("circle")
 .data(set)
 .enter().append("circle") // <-C
 .attr("class", "dot")
 .attr("cx", function(d) { return x(d.x); })
 .attr("cy", function(d) { return y(d.y); })
 .attr("r", 4.5);
 });
 }
// Axes related code omitted

之前的代码在您的浏览器中生成以下可配置插值模式的折线图:

如何做...

线插值

它是如何工作的...

总体来说,这个食谱与之前的类似。使用预定义的数据集生成两行。然而,在这个食谱中,我们允许用户选择特定的行插值模式,然后通过在行生成器上使用interpolate函数来设置该模式(见行A)。

var line = d3.svg.line()
                .interpolate(mode) // <-A
                .x(function(d){return x(d.x);})
                .y(function(d){return y(d.y);});

D3 支持以下插值模式:

  • 线性:线性段,即折线

  • linear-closed:闭合的线性段,即多边形

  • step-before:交替垂直和水平段,类似于步函数

  • step-after:交替水平和垂直段,类似于步函数

  • basis:B 样条,两端有控制点重复

  • basis-open:开放的 B 样条;可能不与起点或终点相交

  • basis-closed:闭合的 B 样条,类似于环

  • bundle:等同于基础,但张力参数用于使样条变直

  • cardinal:基数样条,两端有控制点重复。

  • cardinal-open:开放的基数样条;可能不与起点或终点相交,但会与其他控制点相交

  • cardinal-closed:闭合的基数样条,类似于环

  • monotone:保留 y 单调性的三次插值

此外,在 renderDots 函数(参见代码行 B)中,我们还为每个数据点创建了一个小圆圈作为参考点。这些点是通过 svg:circle 元素创建的,如代码行 C 所示:

function renderDots(svg){ // <-B
        data.forEach(function(set){
             svg.append("g").selectAll("circle")
                .data(set)
              .enter().append("circle") // <-C
                .attr("class", "dot")
                .attr("cx", function(d) { return x(d.x); })
                .attr("cy", function(d) { return y(d.y); })
                .attr("r", 4.5);
        });
}

改变线张力

如果使用基数插值模式(基数、基数开放、基数闭合),则可以通过张力设置进一步修改线。在本教程中,我们将了解如何修改张力以及它对线插值的影响。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter7/line-tension.html

如何操作...

现在,让我们看看如何改变线张力以及它对线生成的影响:

<script type="text/javascript">
    var width = 500,
        height = 500,
        margin = 30,
        duration = 500,    
        x = d3.scale.linear()
            .domain([0, 10])
            .range([margin, width - margin]),
        y = d3.scale.linear()
            .domain([0, 1])
            .range([height - margin, margin]);

    var data = d3.range(10).map(function(i){
            return {x: i, y: (Math.sin(i * 3) + 1) / 2};
        });

    var svg = d3.select("body").append("svg");

    svg.attr("height", height)
        .attr("width", width);

    renderAxes(svg);

 render([1]); 

    function render(tension){
        var line = d3.svg.line()
                .interpolate("cardinal")
                .x(function(d){return x(d.x);})
                .y(function(d){return y(d.y);});

        svg.selectAll("path.line")
                .data(tension)
            .enter()
                .append("path")
                .attr("class", "line");            

 svg.selectAll("path.line")
 .data(tension) // <-A 
 .transition().duration(duration).ease("linear") // <-B
 .attr("d", function(d){
 return line.tension(d)(data); // <-C
 });

        svg.selectAll("circle")
            .data(data)
          .enter().append("circle")
            .attr("class", "dot")
            .attr("cx", function(d) { return x(d.x); })
            .attr("cy", function(d) { return y(d.y); })
            .attr("r", 4.5);
} 
// Axes related code omitted
    ...
</script>
<h4>Line Tension:</h4>
<div class="control-group">
    <button onclick="render([0])">0</button>
    <button onclick="render([0.2])">0.2</button>
    <button onclick="render([0.4])">0.4</button>
    <button onclick="render([0.6])">0.6</button>
    <button onclick="render([0.8])">0.8</button>
    <button onclick="render([1])">1</button>
</div>

上述代码生成一个可配置张力的基数线图:

如何操作...

线张力

工作原理...

张力将基数样条插值张力设置为范围 [0, 1] 内的特定数字。可以使用线生成器的 tension 函数设置张力(参见代码行 C):

svg.selectAll("path.line")
                .data(tension) // <-A                    
            .transition().duration(duration).ease("linear") // <-B
            .attr("d", function(d){
                return line.tension(d)(data);} // <-C 
            ); 

此外,我们还在代码行 B 上启动了一个过渡,以突出张力对线插值的影响。如果未显式设置张力,基数插值默认将张力设置为 0.7

使用面积生成器

使用 D3 线生成器,我们可以技术上生成任何形状的轮廓,然而,即使有不同插值支持,直接使用线(如面积图)绘制面积并不是一件容易的事情。这就是为什么 D3 还提供了一个专门的形状生成器函数,专门用于绘制面积。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter7/area.html

如何操作...

在本教程中,我们将向伪线图添加填充区域,从而有效地将其转换为面积图:

<script type="text/javascript">
    var width = 500,
        height = 500,
        margin = 30,
        duration = 500,
 x = d3.scale.linear() // <-A
 .domain([0, 10])
 .range([margin, width - margin]),
 y = d3.scale.linear()
 .domain([0, 10])
 .range([height - margin, margin]);

 var data = d3.range(11).map(function(i){ // <-B
 return {x: i, y: Math.sin(i)*3 + 5};
 });

    var svg = d3.select("body").append("svg");

    svg.attr("height", height)
        .attr("width", width);        

    renderAxes(svg);

    render("linear");    

    renderDots(svg);

    function render(){
        var line = d3.svg.line()
                .x(function(d){return x(d.x);})
                .y(function(d){return y(d.y);});

        svg.selectAll("path.line")
                .data([data])
            .enter()
                .append("path")
                .attr("class", "line");                

        svg.selectAll("path.line")
                .data([data])       
            .attr("d", function(d){return line(d);});        

 var area = d3.svg.area() // <-C
 .x(function(d) { return x(d.x); }) // <-D
 .y0(y(0)) // <-E
 .y1(function(d) { return y(d.y); }); // <-F

 svg.selectAll("path.area") // <-G
 .data([data])
 .enter()
 .append("path")
 .attr("class", "area")
 .attr("d", function(d){return area(d);}); // <-H
    }

    // Dots rendering code omitted

    // Axes related code omitted
    ...
</script>

上述代码生成了以下视觉输出:

如何操作...

面积生成器

它是如何工作的...

与本章前面提到的使用线生成器配方类似,我们定义了两个比例尺来将数据映射到xy坐标的视觉域(参见行 A),在这个配方中:

x = d3.scale.linear() // <-A
            .domain([0, 10])
            .range([margin, width - margin]),
        y = d3.scale.linear()
            .domain([0, 10])
            .range([height - margin, margin]);

    var data = d3.range(11).map(function(i){ // <-B
            return {x: i, y: Math.sin(i)*3 + 5};
        });

在行B,数据通过一个数学公式生成。然后使用d3.svg.area函数创建面积生成器(参见行C):

var area = d3.svg.area() // <-C
            .x(function(d) { return x(d.x); }) // <-D
            .y0(y(0)) // <-E
            .y1(function(d) { return y(d.y); }); // <-F

如您所见,D3 面积生成器——类似于线生成器——设计用于在二维齐次坐标系中工作。通过x函数定义x坐标的访问函数(参见行D),它简单地使用我们之前定义的x比例尺将数据映射到视觉坐标。对于y坐标,我们为面积生成器提供了两个不同的访问器;一个用于下限(y0)和一个用于上限(y1)坐标。这是面积生成器和线生成器之间的关键区别。D3 面积生成器支持xy轴上的上下限(x0x1y0y1),如果上下限相同,则可以使用简写访问器(xy)。一旦定义了面积生成器,创建面积的方法几乎与线生成器相同。

svg.selectAll("path.area") // <-G
                .data([data])
            .enter()
                .append("path")
                .attr("class", "area")
                .attr("d", function(d){return area(d);}); // <-H

面积也是使用svg:path元素实现的(参见行G)。D3 面积生成器用于在行H上生成svg:path元素的"d"公式,其中数据"d"是其输入参数。

使用面积插值

与 D3 线生成器类似,面积生成器也支持相同的插值模式,因此,它可以在每种模式下与线生成器一起使用。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter7/area-interpolation.html

如何操作...

在这个配方中,我们将展示如何在面积生成器上配置插值模式。这样就可以创建与相应线匹配的匹配插值面积:

    var width = 500,
        height = 500,
        margin = 30,
        x = d3.scale.linear()
            .domain([0, 10])
            .range([margin, width - margin]),
        y = d3.scale.linear()
            .domain([0, 10])
            .range([height - margin, margin]);

    var data = d3.range(11).map(function(i){
        return {x: i, y: Math.sin(i)*3 + 5};
    });

    var svg = d3.select("body").append("svg");

    svg.attr("height", height)
        .attr("width", width);        

    renderAxes(svg);

    render("linear");    

    renderDots(svg);

    function render(mode){
        var line = d3.svg.line()
 .interpolate(mode) // <-A
                .x(function(d){return x(d.x);})
                .y(function(d){return y(d.y);});

        svg.selectAll("path.line")
                .data([data])
            .enter()
                .append("path")
                .attr("class", "line");                

        svg.selectAll("path.line")
                .data([data])       
            .attr("d", function(d){return line(d);});        

        var area = d3.svg.area()
 .interpolate(mode) // <-B
            .x(function(d) { return x(d.x); })
            .y0(height - margin)
            .y1(function(d) { return y(d.y); });

        svg.selectAll("path.area")
                .data([data])
            .enter()
                .append("path")
                .attr("class", "area")

        svg.selectAll("path.area")
            .data([data])
            .attr("d", function(d){return area(d);});        
}
// Dots and Axes related code omitted

以下代码生成一个具有可配置插值模式的伪面积图:

如何操作...

面积插值

它是如何工作的...

这个配方与上一个配方类似,只是在这次配方中,插值模式是基于用户的选项传递的:

var line = d3.svg.line()
                .interpolate(mode) // <-A
                .x(function(d){return x(d.x);})
                .y(function(d){return y(d.y);});

var area = d3.svg.area()
            .interpolate(mode) // <-B
            .x(function(d) { return x(d.x); })
            .y0(y(0))
            .y1(function(d) { return y(d.y); });

如您所见,插值模式是在两行中通过interpolate函数配置的,同时通过面积生成器(参见行AB)。由于 D3 线生成器和面积生成器支持相同的插值模式集,它们可以始终被用来生成与这个配方中看到的匹配的线和面积。

还有更多...

当使用基数模式插值时,D3 面积生成器也支持相同的张力配置,然而,由于它与线生成器的张力支持相同,并且由于本书的范围有限,我们在此不涉及面积张力。

参考以下内容

使用弧生成器

在最常见的形状生成器中——除了线和区域生成器之外——D3 还提供了弧生成器。此时,您可能想知道,“SVG 标准不是已经包含了圆形元素吗?这难道还不够吗?”

简单的回答是“不”。D3 弧生成器比简单的 svg:circle 元素要灵活得多。D3 弧生成器不仅能创建圆形,还能创建圆环(类似甜甜圈)、圆形扇形和圆环扇形,所有这些我们将在本菜谱中学习。更重要的是,弧生成器旨在生成弧(换句话说,不是完整的圆或扇形,而是任意角度的弧)。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter7/arc.html

如何操作...

在本菜谱中,我们将使用弧生成器生成多切片圆形、圆环(甜甜圈)、圆形扇形和圆环扇形。

<script type="text/javascript">
var width = 400,
height = 400,
// angles are in radians
 fullAngle = 2 * Math.PI, // <-A
    colors = d3.scale.category20c();

var svg = d3.select("body").append("svg")
            .attr("class", "pie")
            .attr("height", height)
            .attr("width", width);    

function render(innerRadius, endAngle){
    if(!endAngle) endAngle = fullAngle;

 var data = [ // <-B
 {startAngle: 0, endAngle: 0.1 * endAngle},
 {startAngle: 0.1 * endAngle, endAngle: 0.2 * endAngle},
 {startAngle: 0.2 * endAngle, endAngle: 0.4 * endAngle},
 {startAngle: 0.4 * endAngle, endAngle: 0.6 * endAngle}, 
 {startAngle: 0.6 * endAngle, endAngle: 0.7 * endAngle}, 
 {startAngle: 0.7 * endAngle, endAngle: 0.9 * endAngle}, 
 {startAngle: 0.9 * endAngle, endAngle: endAngle}
 ];

 var arc = d3.svg.arc().outerRadius(200) // <-C
 .innerRadius(innerRadius);

    svg.select("g").remove();

    svg.append("g")
            .attr("transform", "translate(200,200)")
    .selectAll("path.arc")
            .data(data)
        .enter()
            .append("path")
                .attr("class", "arc")
                .attr("fill", function(d, i){return colors(i);})
 .attr("d", function(d, i){
 return arc(d, i); // <-D
});
}

render(0);
</script>

<div class="control-group">
    <button onclick="render(0)">Circle</button>
    <button onclick="render(100)">Annulus(Donut)</button>
    <button onclick="render(0, Math.PI)">Circular Sector</button>
    <button onclick="render(100, Math.PI)">Annulus Sector</button>
</div>

上述代码生成了以下圆形,您可以通过点击按钮将其更改为弧形、扇形或弧扇形,例如,圆环(甜甜圈)生成第二个形状:

如何操作...

弧生成器

它是如何工作的...

理解 D3 弧生成器的最重要部分是其数据结构。D3 弧生成器对其数据有非常具体的要求,如行B所示:

var data = [ // <-B
        {startAngle: 0, endAngle: 0.1 * endAngle},
        {startAngle: 0.1 * endAngle, endAngle: 0.2 * endAngle},
        {startAngle: 0.2 * endAngle, endAngle: 0.4 * endAngle},
        {startAngle: 0.4 * endAngle, endAngle: 0.6 * endAngle},        
        {startAngle: 0.6 * endAngle, endAngle: 0.7 * endAngle},        
        {startAngle: 0.7 * endAngle, endAngle: 0.9 * endAngle},        
        {startAngle: 0.9 * endAngle, endAngle: endAngle}
];

弧数据表的每一行都必须包含两个必填字段,startAngle(起始角)和endAngle(结束角)。角度必须在 [0, 2 * Math.PI] 范围内(见行A)。D3 弧生成器将使用这些角度生成相应的切片,如本菜谱中前面所示。

小贴士

除了起始角和结束角之外,弧数据集还可以包含任何数量的附加字段,然后可以在 D3 函数中访问这些字段以驱动其他视觉表示。

如果您认为根据您拥有的数据计算这些角度将会很麻烦,您完全正确。这就是为什么 D3 提供了特定的布局管理器来帮助您计算这些角度,我们将在下一章中介绍。现在,让我们专注于理解背后的基本机制,以便在介绍布局管理器或您需要手动设置角度时,您将能够很好地完成这些工作。D3 弧生成器是通过使用 d3.svg.arc 函数创建的:

var arc = d3.svg.arc().outerRadius(200) // <-C
                    .innerRadius(innerRadius); 

d3.svg.arc 函数可选地有 outerRadiusinnerRadius 设置。当设置 innerRadius 时,弧生成器将生成一个环面(甜甜圈)的图像,而不是一个圆。最后,D3 弧也是使用 svg:path 元素实现的,因此与线和面积生成器类似,d3.svg.arc 生成器函数可以调用(见行 D)来生成 svg:path 元素的 d 公式:

svg.append("g")
            .attr("transform", "translate(200,200)")
    .selectAll("path.arc")
            .data(data)
        .enter()
            .append("path")
                .attr("class", "arc")
                .attr("fill", function(d, i){return colors(i);})
                .attr("d", function(d, i){
                    return arc(d, i); // <-D
                });

值得在这里提及的一个额外元素是 svg:g 元素。此元素本身不定义任何形状,而是一个容器元素,用于组合其他元素,在这种情况下,是 path.arc 元素。应用于 g 元素的变换应用于所有子元素,同时其属性也被其子元素继承。

实现弧过渡

弧与其他形状(如线和面积)显著不同的一个领域是其过渡效果。到目前为止,我们涵盖的大多数形状,包括简单的 SVG 内置形状,你可以依赖 D3 过渡和插值来处理它们的动画。然而,当处理弧时,情况并非如此。我们将在这个菜谱中探索弧过渡技术。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter7/arc-transition.html

如何做...

在这个菜谱中,我们将动画化一个多切片圆环,每个切片从角度 0 开始过渡到其最终所需的角度,并最终形成一个完整的圆环:

<script type="text/javascript">
var width = 400,
        height = 400,
        endAngle = 2 * Math.PI,
        colors = d3.scale.category20c();

var svg = d3.select("body").append("svg")
        .attr("class", "pie")
        .attr("height", height)
        .attr("width", width);

function render(innerRadius) {

    var data = [
        {startAngle: 0, endAngle: 0.1 * endAngle},
        {startAngle: 0.1 * endAngle, endAngle: 0.2 * endAngle},
        {startAngle: 0.2 * endAngle, endAngle: 0.4 * endAngle},
        {startAngle: 0.4 * endAngle, endAngle: 0.6 * endAngle},
        {startAngle: 0.6 * endAngle, endAngle: 0.7 * endAngle},
        {startAngle: 0.7 * endAngle, endAngle: 0.9 * endAngle},
        {startAngle: 0.9 * endAngle, endAngle: endAngle}
    ];

    var arc = d3.svg.arc().outerRadius(200).innerRadius(innerRadius);

    svg.select("g").remove();

    svg.append("g")
        .attr("transform", "translate(200,200)")
        .selectAll("path.arc")
            .data(data)
        .enter()
            .append("path")
            .attr("class", "arc")
            .attr("fill", function (d, i) {
                return colors(i);
            })
            .transition().duration(1000)
 .attrTween("d", function (d) { // <-A 
 var start = {startAngle: 0, endAngle: 0}; // <-B
 var interpolate = d3.interpolate(start, d); // <-C
 return function (t) {
 return arc(interpolate(t)); // <-D
 };
 });
}

render(100);
</script>

上述代码生成一个弧,它开始旋转并最终形成一个完整的圆环:

如何做...

带插值的弧过渡

]

它是如何工作的...

面对这样的过渡要求时,你首先可能想到的是使用纯 D3 过渡,同时依赖内置插值来生成动画。以下代码片段将完成这项工作:

svg.append("g")
        .attr("transform", "translate(200,200)")
        .selectAll("path.arc")
            .data(data)
        .enter()
            .append("path")
            .attr("class", "arc")
            .attr("fill", function (d, i) {
                return colors(i);
            })
 .attr("d", function(d){
 return arc({startAngle: 0, endAngle: 0});
 })
 .transition().duration(1000).ease("linear")
 .attr("d", function(d){return arc(d);});

如前述代码片段中突出显示的行所示,我们最初创建了一个具有 startAngleendAngle 都设置为零的切片路径。然后,通过过渡,我们使用弧生成器函数 arc(d) 将路径 "d" 属性插值到其最终角度。这种方法看起来似乎有道理,然而,它生成的是以下所示的过渡:

它是如何工作的...

无插值的弧过渡

这显然不是我们想要的动画。这种奇怪过渡的原因是,通过直接在 svg:path 属性 "d" 上创建过渡,我们指示 D3 插值这个字符串:

d="M1.2246063538223773e-14,-200A200,200 0 0,1 1.2246063538223773e-14,-200L6.123031769111886e-15,-100A100,100 0 0,0 6.123031769111886e-15,-100Z"

将此字符串线性化:

d="M1.2246063538223773e-14,-200A200,200 0 0,1 117.55705045849463,-161.80339887498948L58.778525229247315,-80.90169943749474A100,100 0 0,0 6.123031769111886e-15,-100Z"

因此,这种特定的过渡效果。

注意

虽然这个过渡效果不是我们在这个例子中想要的,但这仍然是一个很好的展示,说明了内置的 D3 过渡是多么灵活和强大。

为了实现我们想要的过渡效果,我们需要利用 D3 属性缓动(有关缓动的详细描述,请参阅第六章中的使用缓动配方,以风格过渡):

svg.append("g")
        .attr("transform", "translate(200,200)")
        .selectAll("path.arc")
            .data(data)
        .enter()
            .append("path")
            .attr("class", "arc")
            .attr("fill", function (d, i) {
                return colors(i);
            })
            .transition().duration(1000)
            .attrTween("d", function (d) { // <-A
                var start = {startAngle: 0, endAngle: 0}; // <-B
                var interpolate = d3.interpolate(start, d); // <-C
                return function (t) {
                    return arc(interpolate(t)); // <-D
                };
            });

在这里,我们不是直接过渡 svg:path 属性的 "d",而是在行 A 上创建了一个缓动函数。如您所回忆的,D3 的 attrTween 期望一个用于缓动函数的工厂函数。在这种情况下,我们从角度零开始缓动(参见行 B)。然后在行 C 上创建了一个复合对象插值器,它将为每个切片插值起始和结束角度。最后在行 D 上,使用弧生成器根据已经插值的角生成适当的 svg:path 公式。这就是如何通过自定义属性缓动创建平滑过渡的适当角度弧的方法。

还有更多...

D3 还提供了对其他形状生成器的支持,例如符号、和弦和斜线。然而,由于它们的简单性和本书的有限范围,我们在这里不会单独介绍它们,尽管我们将在下一章的其他更复杂的视觉结构中介绍它们。更重要的是,通过我们对本章中介绍的这些形状生成器的扎实理解,您应该能够轻松地掌握其他 D3 形状生成器。

参见

  • 更多关于过渡和缓动的信息,请参阅第六章中的使用缓动配方,以风格过渡

第八章. 图表化

在本章中,我们将涵盖以下内容:

  • 创建折线图

  • 创建面积图

  • 创建散点图图表

  • 创建气泡图

  • 创建条形图

简介

在本章中,我们将把注意力转向数据可视化中最古老且最值得信赖的伴侣之一——图表。图表是对数据进行良好定义和理解的图形表示;以下定义只是证实了这一点:

(在图表中)数据通过符号表示,例如条形图中的条形、折线图中的线条或饼图中的切片。

Jensen C. & Anderson L. (1991)

当图表用于数据可视化时,它们被广泛理解的图形语义和语法减轻了您的可视化观众学习图形隐喻含义的负担。因此,他们可以专注于数据本身以及通过可视化生成的信息。本章的目标不仅是介绍一些常用的图表类型,还演示了我们将学到的一些主题和技术如何结合并利用 D3 来制作流畅的交互式图表。

本章中的食谱比我们迄今为止遇到的食谱要长得多,因为它们旨在实现功能齐全的可重用图表。我已经尝试将其分解为不同的部分,并使用一致的图表结构来简化您的阅读体验。然而,仍然强烈建议在阅读本章时,同时打开浏览器中的配套代码示例和您的文本编辑器,以最大限度地减少潜在的混淆并最大化收益。

D3 图表惯例:在我们深入创建第一个可重用图表之前,我们需要了解 D3 社区中普遍接受的某些图表惯例,否则我们可能会冒着创建让用户困惑而不是帮助他们的图表库的风险。

注意

如您所想象,D3 图表通常使用 SVG 而不是 HTML 来实现;然而,我们在这里讨论的惯例也适用于基于 HTML 的图表,尽管实现细节将有所不同。

让我们先看看以下图表:

简介

D3 图表惯例

注意

要了解 D3 创建者的这一惯例解释,请访问bl.ocks.org/mbostock/3019563

如此图表所示,SVG 图像中的原点(0, 0)位于其左上角,这是预期的,然而,这一惯例最重要的方面是关于如何定义图表边距,以及进一步轴线的位置。

  • 边距:首先,让我们看看这一惯例最重要的方面——边距。正如我们所看到的,对于每个图表,都有四个不同的边距设置:左边距、右边距、上边距和下边距。灵活的图表实现应该允许用户为这些边距中的每一个设置不同的值,我们将在后面的食谱中看到如何实现这一点。

  • 坐标平移:其次,这个约定还建议使用 SVG 的 translate 变换 translate(margin.left, margin.top) 来定义图表主体(灰色区域)的坐标参考。这种平移有效地将图表主体区域移动到所需的位置,这种方法的一个额外好处是,通过改变图表主体坐标的参考框架,简化了在图表主体内部创建子元素的工作,因为边距大小变得无关紧要。对于图表主体内部的任何子元素,其原点(0, 0)现在位于图表主体区域的左上角。

  • :最后,这个约定的最后一个方面是关于图表轴如何放置以及放置在哪里。如图所示,图表轴放置在图表边距内部,而不是作为图表主体的一部分。这种方法的优势在于将轴视为图表中的外围元素,因此不会混淆图表主体的实现,并且还使轴的渲染逻辑与图表无关且易于重用。

现在,让我们利用迄今为止学到的所有知识和技巧,创建我们的第一个可重用的 D3 图表。

创建折线图

折线图是一种常见的基本图表类型,在许多领域得到广泛应用。这种图表由一系列通过直线段连接的数据点组成。折线图通常由两条垂直的轴:x 轴和 y 轴所包围。在本食谱中,我们将看到如何使用 D3 将这种基本图表实现为一个可重用的 JavaScript 对象,该对象可以配置为在不同的尺度上显示多个数据系列。此外,我们还将展示实现带有动画的动态多数据系列更新的技术。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter8/line-chart.html

非常推荐在阅读本食谱时打开相应的代码示例。

如何做到这一点...

让我们看看实现这种图表类型的代码。由于代码长度较长,我们在这里只展示代码的概要,具体细节将在接下来的 工作原理... 部分中详细介绍。

<script type="text/javascript">
// First we define the chart object using a functional objectfunction lineChart() { // <-1A
...
    // main render function 
    _chart.render = function () { // <-2A
    ...
    };

    // axes rendering function
    function renderAxes(svg) {
    ...
    }
  ...

    // function to render chart body
    function renderBody(svg) { // <-2D        
    ...
    }

    // function to render lines
    function renderLines() {
    ...
    }

 // function to render data points
    function renderDots() {

    }

    return _chart; // <-1E
}

本食谱生成以下图表:

如何做到这一点...

折线图

工作原理...

如我们所见,这个食谱比我们迄今为止遇到的所有内容都要复杂得多,因此现在我将将其分解成多个具有不同重点的详细部分。

图表对象和属性:首先,我们将看看这个图表对象是如何创建的,以及如何检索和设置与图表对象关联的属性。

function lineChart() { // <-1A
  var _chart = {};

  var _width = 600, _height = 300, // <-1B
    _margins = {top: 30, left: 30, right: 30, bottom: 30},
    _x, _y,
    _data = [],
    _colors = d3.scale.category10(),
    _svg,
    _bodyG,
    _line;
  ...
  _chart.height = function (h) {// <-1C
    if (!arguments.length) return _height;
    _height = h;
    return _chart;
  };

  _chart.margins = function (m) {
    if (!arguments.length) return _margins;
    _margins = m;
    return _chart;
  };
...
  _chart.addSeries = function (series) { // <-1D
    _data.push(series);
    return _chart;
  };
...
   return _chart; // <-1E
}

...

var chart = lineChart()
  .x(d3.scale.linear().domain([0, 10]))
  .y(d3.scale.linear().domain([0, 10]));

data.forEach(function (series) {
  chart.addSeries(series);
});

chart.render();

如我们所见,图表对象是在第 1A 行使用名为lineChart的函数定义的,遵循我们在第一章中讨论的函数对象模式,使用 D3.js 入门。利用函数对象模式提供的信息隐藏的更大灵活性,我们定义了一系列内部属性,所有属性名都以下划线开头(第 1B 行)。其中一些属性通过提供访问器函数(第 1C 行)公开。公开可访问的属性包括:

  • width: 图表 SVG 总宽度(以像素为单位)

  • height: 图表 SVG 总高度(以像素为单位)

  • margins: 图表边距

  • colors: 用于区分不同数据系列的图表序数颜色刻度

  • x: x 轴刻度

  • y: y 轴刻度

    访问器函数是通过我们在第一章中介绍的技术实现的,使用 D3.js 入门,有效地将获取器和设置器函数结合在一个函数中,当没有提供参数时作为获取器使用,当提供参数时作为设置器使用(第 1C 行)。此外,lineChart函数及其访问器都返回一个图表实例,从而允许函数链式调用。最后,图表对象还提供了一个addSeries函数,该函数简单地将数据数组(series)推入其内部数据存储数组(_data),见第 1D 行。

    图表主体框架渲染:在介绍基本图表对象及其属性之后,本可重用图表实现的下一个方面是图表主体svg:g元素的渲染及其裁剪路径生成。

    _chart.render = function () { // <-2A
      if (!_svg) {
        _svg = d3.select("body").append("svg") // <-2B
          .attr("height", _height)
          .attr("width", _width);
    
        renderAxes(_svg);
    
        defineBodyClip(_svg);
      }
    
      renderBody(_svg);
    };
    ...
    function defineBodyClip(svg) { // <-2C
      var padding = 5;
    
      svg.append("defs")
        .append("clipPath")
        .attr("id", "body-clip")
        .append("rect")
        .attr("x", 0 - padding)
        .attr("y", 0)
        .attr("width", quadrantWidth() + 2 * padding)
        .attr("height", quadrantHeight());
      }
    
    function renderBody(svg) { // <-2D
      if (!_bodyG)
        _bodyG = svg.append("g")
          .attr("class", "body")
          .attr("transform", "translate(" 
            + xStart() + "," 
            + yEnd() + ")") // <-2E
          .attr("clip-path", "url(#body-clip)");        
    
      renderLines();
    
      renderDots();
    }
    ...
    

    在第 2A 行定义的render函数负责创建svg:svg元素并设置其widthheight(第 2B 行)。之后,它创建一个覆盖整个图表主体区域的svg:clipPath元素。svg:clipPath元素用于限制可以应用绘画的区域。在我们的例子中,我们使用它来限制线条和点可以绘制的地方(仅限于图表主体区域)。此代码生成以下 SVG 元素结构,该结构定义了图表主体:

    工作原理...

    注意

    关于裁剪和遮罩的更多信息,请访问www.w3.org/TR/SVG/masking.html

    在第 2D 行定义的renderBody函数生成一个svg:g元素,该元素将所有图表主体内容包裹起来,并设置了一个根据我们在前一部分讨论的图表边距约定进行的平移(第 2E 行)。

    渲染坐标轴:坐标轴在renderAxes函数(第 3A 行)中渲染。

    function renderAxes(svg) { // <-3A
      var axesG = svg.append("g")
        .attr("class", "axes");
    
      renderXAxis(axesG);
    
      renderYAxis(axesG);
    }
    

    如前一章所述,x 轴和 y 轴都渲染在图表边距区域内。我们不会深入讨论坐标轴渲染的细节,因为我们已经在第五章中详细讨论了这一主题,玩转坐标轴

    渲染数据系列:到目前为止,我们在这个食谱中讨论的所有内容并不仅限于这种图表类型,而是一个与其他笛卡尔坐标系图表类型共享的框架。最后,现在我们将讨论如何为多个数据系列创建线段和点。让我们看一下以下负责数据系列渲染的代码片段。

    function renderLines() { 
      _line = d3.svg.line() // <-4A
        .x(function (d) { return _x(d.x); })
        .y(function (d) { return _y(d.y); });
    
      _bodyG.selectAll("path.line")
        .data(_data)
        .enter() // <-4B
        .append("path")                
        .style("stroke", function (d, i) { 
          return _colors(i); // <-4C
        })
        .attr("class", "line");
    
      _bodyG.selectAll("path.line")
        .data(_data)
        .transition() // <-4D
        .attr("d", function (d) { return _line(d); });
    }
    
    function renderDots() {
      _data.forEach(function (list, i) {
        _bodyG.selectAll("circle._" + i) // <-4E
          .data(list)
          .enter()
          .append("circle")
          .attr("class", "dot _" + i);
    
        _bodyG.selectAll("circle._" + i)
          .data(list)                    
          .style("stroke", function (d, i) { 
            return _colors(i); // <-4F
          })
          .transition() // <-4G
          .attr("cx", function (d) { return _x(d.x); })
          .attr("cy", function (d) { return _y(d.y); })
          .attr("r", 4.5);
        });
    }
    

    线段和点是通过我们在第七章中介绍的技术生成的,进入形状d3.svg.line生成器在第 4A 行创建,用于创建映射数据系列的svg:path。使用 Enter-and-Update 模式创建数据线(第 4B 行)。第 4C 行根据其索引为每条数据线设置不同的颜色。最后,第 4E 行在更新模式下设置过渡,以便在每次更新时平滑地移动数据线。renderDots函数执行类似的渲染逻辑,生成代表每个数据点的svg:circle元素集合(第 4E 行),根据数据系列索引(第 4F 行)协调其颜色,并在第 4G 行上最终启动过渡,这样点就可以在数据更新时与线一起移动。

    如本食谱所示,创建一个可重用的图表组件实际上需要做很多工作。然而,在创建外围图形元素和访问器方法时,需要超过三分之二的代码。因此,在实际项目中,你可以提取这部分逻辑,并将此实现的大部分用于其他图表;尽管我们没有在我们的食谱中这样做,以减少复杂性,这样你可以快速掌握图表渲染的所有方面。由于本书的范围有限,在后面的食谱中,我们将省略所有外围渲染逻辑,而只关注与每种图表类型相关的核心逻辑。

创建面积图

面积图或面积图与折线图非常相似,在很大程度上是基于折线图实现的。面积图与折线图的主要区别在于,在面积图中,轴和线之间的区域被填充了颜色或纹理。在本食谱中,我们将探讨实现一种称为分层面积图的面积图技术。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter8/area-chart.html

如何做...

由于面积图实现主要基于折线图实现,并且它共享许多常见的图形元素,如轴和裁剪路径,因此在本食谱中,我们只展示与面积图实现特定相关的代码:

...
function renderBody(svg) {
  if (!_bodyG)
    _bodyG = svg.append("g")
      .attr("class", "body")
      .attr("transform", "translate(" 
        + xStart() + "," 
        + yEnd() + ")") 
      .attr("clip-path", "url(#body-clip)");        

  renderLines();

  renderAreas();

  renderDots();
}

function renderLines() {
  _line = d3.svg.line()
    .x(function (d) { return _x(d.x); })
    .y(function (d) { return _y(d.y); });

  _bodyG.selectAll("path.line")
    .data(_data)
    .enter()
    .append("path")
    .style("stroke", function (d, i) { 
      return _colors(i); 
    })
    .attr("class", "line");

  _bodyG.selectAll("path.line")
    .data(_data)
    .transition()
    .attr("d", function (d) { return _line(d); });
}

function renderDots() {
  _data.forEach(function (list, i) {
    _bodyG.selectAll("circle._" + i)
      .data(list)
      .enter().append("circle")
      .attr("class", "dot _" + i);

    _bodyG.selectAll("circle._" + i)
      .data(list)
      .style("stroke", function (d, i) { 
        return _colors(i); 
      })
      .transition()
      .attr("cx", function (d) { return _x(d.x); })
      .attr("cy", function (d) { return _y(d.y); })
      .attr("r", 4.5);
  });
}

function renderAreas() {
 var area = d3.svg.area() // <-A
 .x(function(d) { return _x(d.x); })
 .y0(yStart())
 .y1(function(d) { return _y(d.y); });

 _bodyG.selectAll("path.area")
 .data(_data)
 .enter() // <-B
 .append("path")
 .style("fill", function (d, i) { 
 return _colors(i); 
 })
 .attr("class", "area");

 _bodyG.selectAll("path.area")
 .data(_data)
 .transition() // <-C
 .attr("d", function (d) { return area(d); });
}
...

本食谱生成了以下分层面积图:

如何做...

分层面积图

它是如何工作的...

如前所述,由于区域图实现基于我们的线形图实现,实现的大部分内容是相同的。事实上,区域图需要渲染线形图中实现的精确线和点。关键的区别在于renderAreas函数。在本教程中,我们依赖于第七章中讨论的区域生成技术,即“形状入门”。在行 A 上创建了d3.svg.area生成器,其上边线与线匹配,而下边线(y0)固定在 x 轴上。

var area = d3.svg.area() // <-A
  .x(function(d) { return _x(d.x); })
  .y0(yStart())
  .y1(function(d) { return _y(d.y); });

一旦定义了区域生成器,就采用经典的“进入并更新”模式来创建和更新区域。在进入情况(行 B)中,为每个数据系列创建了一个svg:path元素,并使用其系列索引进行着色,以便它与我们的线和点匹配颜色(行 C)。

_bodyG.selectAll("path.area")
  .data(_data)
  .enter() // <-B
  .append("path")
  .style("fill", function (d, i) { 
    return _colors(i); // <-C
  })
  .attr("class", "area");

当数据更新时,以及对于新创建的区域,我们开始一个过渡(行 D)来更新区域svg:path元素的d属性到所需的形状(行 E)。

_bodyG.selectAll("path.area")
  .data(_data)
  .transition() // <-D
  .attr("d", function (d) { 
    return area(d); // <-E
  });

由于我们知道线形图实现更新时会同时动画化线和点,因此我们这里的区域更新过渡有效地允许区域根据图表中的线和点进行动画化和移动。

最后,我们还添加了path.area的 CSS 样式以降低其不透明度,使区域变得透明;因此,允许我们期望的分层效果。

.area {
    stroke: none;
    fill-opacity: .2;
}

创建散点图图表

散点图或散点图是另一种常见的图表类型,用于在笛卡尔坐标系中显示具有两个不同变量的数据点。散点图在探索聚类和分类问题时特别有用。在本教程中,我们将学习如何在 D3 中实现多系列散点图图表。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter8/scatterplot-chart.html

如何做...

散点图是另一种使用笛卡尔坐标的图表。因此,其实现的大部分内容与我们之前介绍过的图表非常相似,因此有关外围图形元素的代码在此书中再次省略以节省空间。请查阅配套代码以获取完整的实现。

...
_symbolTypes = d3.scale.ordinal() // <-A
  .range(["circle",
    "cross",
    "diamond",
    "square",
    "triangle-down",
    "triangle-up"]);
...
function renderBody(svg) {
  if (!_bodyG)
    _bodyG = svg.append("g")
      .attr("class", "body")                    
      .attr("transform", "translate(" 
        + xStart() + "," 
        + yEnd() + ")") 
      .attr("clip-path", "url(#body-clip)");

  renderSymbols();
}

function renderSymbols() { // <-B
  _data.forEach(function (list, i) {
    _bodyG.selectAll("path._" + i)
      .data(list)
      .enter()
      .append("path")
      .attr("class", "symbol _" + i);

    _bodyG.selectAll("path._" + i)
      .data(list)
      .classed(_symbolTypes(i), true)
      .transition()
      .attr("transform", function(d){
        return "translate("
          + _x(d.x)
          + ","
          + _y(d.y)
          + ")";
      })
      .attr("d", 
    d3.svg.symbol().type(_symbolTypes(i)));
  });
}
...

本教程生成散点图图表:

如何做...

散点图图表

它是如何工作的...

散点图图表的内容主要由第 B 行的 renderSymbols 函数渲染。你可能已经注意到,renderSymbols 函数的实现与我们在 创建折线图 菜谱中讨论的 renderDots 函数非常相似。这并非偶然,因为两者都试图在二维笛卡尔坐标系上绘制数据点(x 和 y)。在绘制点的情况下,我们创建 svg:circle 元素,而在散点图中,我们需要创建 d3.svg.symbol 元素。D3 提供了一系列预定义的符号,可以轻松生成并使用 svg:path 元素渲染。在第 A 行中,我们定义了一个序数比例,允许将数据系列索引映射到不同的符号类型:

_symbolTypes = d3.scale.ordinal() // <-A
  .range(["circle",
    "cross",
    "diamond",
    "square",
    "triangle-down",
    "triangle-up"]);

使用符号绘制数据点相当直接。首先,我们遍历数据系列数组,并为每个数据系列创建一组 svg:path 元素,代表系列中的每个数据点。

_data.forEach(function (list, i) {
  _bodyG.selectAll("path._" + i)
    .data(list)
    .enter()
    .append("path")
    .attr("class", "symbol _" + i);
    ...
});

每当数据系列更新时,以及对于新创建的符号,我们使用带有过渡效果的更新(第 C 行),将它们放置在正确的坐标位置,并使用 SVG 平移变换(第 D 行)。

_bodyG.selectAll("path._" + i)
  .data(list)
    .classed(_symbolTypes(i), true)
  .transition() // <-C
    .attr("transform", function(d){
      return "translate(" // <-D
        + _x(d.x) 
        + "," 
        + _y(d.y) 
        + ")";
    })
    .attr("d", 
      d3.svg.symbol() // <-E
      .type(_symbolTypes(i))
  );

最后,每个 svg:path 元素的 d 属性是通过 d3.svg.symbol 生成函数生成的,如第 E 行所示。

创建气泡图

气泡图是一种典型的可视化,能够显示三个数据维度。每个具有三个数据点的数据实体在笛卡尔坐标系上被可视化为一个气泡(或圆盘),使用两个不同的变量通过 x 轴和 y 轴表示,类似于散点图图表。而第三个维度则使用气泡的半径(圆盘的大小)表示。气泡图在帮助理解数据实体之间的关系时特别有用。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter8/bubble-chart.html

如何做...

在本菜谱中,我们将探讨使用 D3 实现典型气泡图的技术和方法。以下代码示例显示了气泡图的重要实现方面,省略了访问器和外围图形实现细节。

...
var _width = 600, _height = 300,
  _margins = {top: 30, left: 30, right: 30, bottom: 30},
  _x, _y, _r, // <-A
  _data = [],
  _colors = d3.scale.category10(),
  _svg,
  _bodyG;

  _chart.render = function () {
    if (!_svg) {
      _svg = d3.select("body").append("svg")
      .attr("height", _height)
      .attr("width", _width);

    renderAxes(_svg);

    defineBodyClip(_svg);
  }

  renderBody(_svg);
};
...
function renderBody(svg) {
  if (!_bodyG)
    _bodyG = svg.append("g")
      .attr("class", "body")
      .attr("transform", "translate(" 
        + xStart() 
        + "," 
        + yEnd() + ")")
      .attr("clip-path", "url(#body-clip)");
  renderBubbles();
}

function renderBubbles() {
 _r.range([0, 50]); // <-B

 _data.forEach(function (list, i) {
 _bodyG.selectAll("circle._" + i)
 .data(list)
 .enter()
 .append("circle") // <-C
 .attr("class", "bubble _" + i);

 _bodyG.selectAll("circle._" + i)
 .data(list)
 .style("stroke", function (d, j) { 
 return _colors(j); 
 })
 .style("fill", function (d, j) { 
 return _colors(j); 
 })
 .transition()
 .attr("cx", function (d) { 
 return _x(d.x); // <-D
 })
 .attr("cy", function (d) { 
 return _y(d.y); // <-E
 })
 .attr("r", function (d) { 
 return _r(d.r); // <-F
 });
 });
}
...

此菜谱生成了以下可视化:

如何做...

气泡图

它是如何工作的...

总体而言,气泡图实现遵循本章迄今为止介绍的其他图表实现的相同模式。然而,由于在气泡图中我们想要可视化三个不同的维度(x、y 和半径)而不是两个,因此在此实现中添加了一个新的比例 _r(第 A 行)。

var _width = 600, _height = 300,
  _margins = {top: 30, left: 30, right: 30, bottom: 30},
  _x, _y, _r, // <-A
  _data = [],
  _colors = d3.scale.category10(),
  _svg,
  _bodyG;

大多数气泡图相关的实现细节都由 renderBubbles 函数处理。它从设置半径刻度上的范围(行 B)开始。当然,我们也可以在我们的图表实现中使半径范围可配置;然而,为了简单起见,我们选择在这里显式设置它:

function renderBubbles() {
  _r.range([0, 50]); // <-B

  _data.forEach(function (list, i) {
    _bodyG.selectAll("circle._" + i)
      .data(list)
      .enter()
      .append("circle") // <-C
      .attr("class", "bubble _" + i);

    _bodyG.selectAll("circle._" + i)
      .data(list)
      .style("stroke", function (d, j) { 
        return _colors(j); 
      })
      .style("fill", function (d, j) { 
        return _colors(j); 
      })
      .transition()
      .attr("cx", function (d) { 
        return _x(d.x); // <-D
      })
      .attr("cy", function (d) { 
        return _y(d.y); // <-E
      })
      .attr("r", function (d) { 
        return _r(d.r); // <-F
      });
  });
}

一旦设置了范围,然后我们遍历我们的数据系列,并为每个系列创建一组 svg:circle 元素(行 C)。最后,我们在最后一节中处理新创建的气泡及其更新,其中 svg:circle 元素通过其 cxcy 属性着色并放置到正确的坐标(行 D 和 E)。最后,气泡的大小通过其半径属性 r 控制使用我们之前定义的 _r 缩放(行 F)。

小贴士

在某些气泡图实现中,实现者还利用每个气泡的颜色来可视化第四个数据维度,尽管有些人认为这种视觉表示难以理解且多余。

创建条形图

条形图是一种使用水平(行图)或垂直(柱状图)矩形条进行可视化的图表,其长度与它们所代表的值成比例。在这个配方中,我们将使用 D3 实现一个柱状图。柱状图能够同时通过其 y 轴视觉表示两个变量;换句话说,条形的高度和其 x 轴。x 轴的值可以是离散的或连续的(例如,直方图)。在我们的例子中,我们选择在 x 轴上可视化连续值,从而有效地实现直方图。然而,相同的技巧也可以用于处理离散值。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter8/bar-chart.html

如何操作...

以下代码示例展示了直方图的重要实现方面,省略了访问器和外围图形实现细节。

...
var _width = 600, _height = 250,
  _margins = {top: 30, left: 30, right: 30, bottom: 30},
  _x, _y,
  _data = [],
  _colors = d3.scale.category10(),
  _svg,
  _bodyG;

  _chart.render = function () {
    if (!_svg) {
      _svg = d3.select("body").append("svg")
        .attr("height", _height)
        .attr("width", _width);

    renderAxes(_svg);

    defineBodyClip(_svg);
  }

  renderBody(_svg);
};
...
function renderBody(svg) {
  if (!_bodyG)
    _bodyG = svg.append("g")
      .attr("class", "body")
      .attr("transform", "translate(" 
        + xStart() 
        + "," 
        + yEnd() + ")")
      .attr("clip-path", "url(#body-clip)");

  renderBars();
  }

function renderBars() {
 var padding = 2; // <-A

 _bodyG.selectAll("rect.bar")
 .data(_data)
 .enter()
 .append("rect") // <-B
 .attr("class", "bar");

 _bodyG.selectAll("rect.bar")
 .data(_data) 
 .transition()
 .attr("x", function (d) { 
 return _x(d.x); // <-C
 })
 .attr("y", function (d) { 
 return _y(d.y); // <-D 
 })
 .attr("height", function (d) { 
 return yStart() - _y(d.y); // <-E
 })
 .attr("width", function(d){
 return Math.floor(quadrantWidth() / _data.length) - padding;
 });
}
...

这个配方生成了以下可视化:

如何操作...

条形图(直方图)

它是如何工作的...

这里的一个主要区别是条形图实现不支持多个数据系列。因此,与迄今为止我们处理其他图表时使用的一个存储多个数据系列的二维数组不同,在这个实现中,_data 数组直接存储一组数据点。与条形图相关的可视化逻辑主要位于 renderBars 函数中。

function renderBars() {
  var padding = 2; // <-A
  ...
}

在第一步中,我们定义了条之间的填充(行 A),这样我们就可以在以后自动计算每个条的宽度。之后,我们为每个数据点生成一个 svg:rect 元素(条)。然后,我们打开以下链接:

_bodyG.selectAll("rect.bar")
  .data(_data)
  .enter()
  .append("rect") // <-B
  .attr("class", "bar");

然后在更新部分,我们使用每个条的xy属性(行 C 和 D)将其放置在正确的坐标位置,并将每个条延伸到底部,使其与 x 轴接触,高度自适应地计算在行 E。

_bodyG.selectAll("rect.bar")
  .data(_data)
  .transition()
  .attr("x", function (d) { 
    return _x(d.x); // <-C
  })
  .attr("y", function (d) { 
    return _y(d.y); // <-D 
  })
  .attr("height", function (d) { 
    return yStart() - _y(d.y); // <-E
  })

最后,我们使用条的数量以及我们之前定义的填充值来计算每个条的最优宽度。

.attr("width", function(d){
  return Math.floor(quadrantWidth() / _data.length) - padding;
});

当然,在更灵活的实现中,我们可以将填充宽度设置为可配置的,而不是固定为 2 像素。

参见

在计划为您的下一个可视化项目实现自己的可重用图表之前,请确保您还检查以下基于 D3 的开放源代码可重用图表项目:

第九章. 布局它们

在本章中,我们将涵盖:

  • 构建饼图

  • 构建堆叠面积图

  • 构建树状图

  • 构建树

  • 构建围栏图

简介

本章的重点是 D3 的布局——这是一个我们之前未曾遇到的概念。正如预期的那样,D3 布局是一组算法,用于计算和生成一组元素的放置信息。然而,在我们深入具体细节之前,有一些关键属性值得提及:

  • 布局是数据:布局完全是数据驱动的,它们不会直接生成任何图形或显示相关的输出。这使得它们可以与 SVG 或 canvas 一起使用,甚至在没有图形输出的情况下也可以重复使用

  • 抽象和可重用:布局是抽象的,允许高度灵活性和可重用性。您可以通过各种不同的有趣方式组合和重用布局。

  • 布局是不同的:每个布局都是不同的。D3 提供的每个布局都专注于一个非常特殊的图形需求和数据结构。

  • 无状态:布局主要是无状态的,以简化其使用。这里的无状态意味着布局通常像函数一样,可以用不同的输入数据多次调用,并生成不同的布局输出。

布局是 D3 中有趣且强大的概念。在本章中,我们将通过创建利用这些布局的完整功能可视化来探索 D3 中最常用的布局。

构建饼图

饼图或圆形图是一个包含多个扇区的圆形图,用于说明数值比例。在本配方中,我们将探索涉及 D3 饼图布局的技术,以构建一个功能齐全的饼图。在第七章“进入形状”中,很明显,直接使用 D3 弧生成器是一个非常繁琐的工作。每个弧生成器都期望以下数据格式:

var data = [
  {startAngle: 0, endAngle: 0.6283185307179586}, 
  {startAngle: 0.6283185307179586, endAngle: 1.2566370614359172},
  ...
  {startAngle: 5.654866776461628, endAngle: 6.283185307179586}
];

这本质上需要计算整个圆周2 * Math.PI中每个切片的角度分区。显然,这个过程可以通过一个算法自动完成,这正是d3.layout.pie设计的目的。在本配方中,我们将看到如何使用饼图布局来实现一个功能齐全的饼图。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter9/pie-chart.html.

如何做...

饼图或圆形图是一个分为扇区(切片)的圆形图。饼图在许多领域都很受欢迎,广泛用于展示不同实体之间的关系,尽管并非没有批评。让我们首先看看如何使用d3.pie.layout实现饼图。

<script type="text/javascript">
  function pieChart() {
    var _chart = {};

    var _width = 500, _height = 500,
      _data = [],
      _colors = d3.scale.category20(),
      _svg,
      _bodyG,
      _pieG,
      _radius = 200,
      _innerRadius = 100;

      _chart.render = function () {
        if (!_svg) {
          _svg = d3.select("body").append("svg")
            .attr("height", _height)
            .attr("width", _width);
        }

      renderBody(_svg);
  };

  function renderBody(svg) {
    if (!_bodyG)
      _bodyG = svg.append("g")
        .attr("class", "body");

    renderPie();
  }

  function renderPie() {
    var pie = d3.layout.pie()
      .sort(function (d) {
        return d.id;
      })
      .value(function (d) {
        return d.value;
      });

    var arc = d3.svg.arc()
      .outerRadius(_radius)
      .innerRadius(_innerRadius);

    if (!_pieG)
      _pieG = _bodyG.append("g")
        .attr("class", "pie")
        .attr("transform", "translate(" + _radius + "," + _radius + ")");

    renderSlices(pie, arc);

    renderLabels(pie, arc);
  }

  function renderSlices(pie, arc) {
  // explained in detail in the'how it works...' section
  ...
  }

  function renderLabels(pie, arc) {
  // explained in detail in the 'how it works...' section
  ...
  }
  ...
  return _chart;
}
...
</script>

此配方生成了以下饼图:

如何做...

圆环图

它是如何工作的...

这个配方是在我们学到的第七章,进入形状的基础上构建的。一个主要的不同之处在于,我们依赖于d3.layout.pie来为我们转换原始数据为弧数据。饼图布局在行 A 上创建,同时指定了排序和值访问器。

var pie = d3.layout.pie() // <-A
  .sort(function (d) {
    return d.id;
  })
  .value(function (d) {
    return d.value;
  });

sort函数告诉饼图布局按其 ID 字段对切片进行排序,这样我们就可以在切片之间保持稳定的顺序。如果没有排序,默认情况下,饼图布局将按值对切片进行排序,导致每次我们更新饼图时切片都会交换。value函数用于提供值访问器,在我们的例子中返回value字段。现在,在饼图布局中渲染切片时,我们直接将饼图布局作为数据(记住布局是数据)来生成弧svg:path元素(行 B)。

function renderSlices(pie, arc) {
  var slices = _pieG.selectAll("path.arc")
    .data(pie(_data)); // <-B

  slices.enter()
    .append("path")
    .attr("class", "arc")
    .attr("fill", function (d, i) {
      return _colors(i);
    });

  slices.transition()
    .attrTween("d", function (d) {
      var currentArc = this.__current__;//<-C

      if (!currentArc)
        currentArc = {startAngle: 0, 
          endAngle: 0};

      var interpolate = d3.interpolate(
        currentArc, d);
      this.__current__ = interpolate(1);//<-D
        return function (t) {
          return arc(interpolate(t));
        };
    });
}

其余的渲染逻辑基本上与我们学到的第七章,进入形状中相同,只有一个例外是在行 C。在行 C 中,我们从元素中检索当前弧值,以便过渡可以从当前角度开始而不是从零开始。然后在行 D 中,我们将当前弧值重置为最新值,这样下次我们更新饼图数据时,我们可以重复状态性过渡。

小贴士

技术 – 状态性可视化

在 DOM 元素上注入值的技术是将状态性引入你的可视化的常见方法。换句话说,如果你需要你的可视化记住它们之前的状态,你可以在 DOM 元素中保存它们。

最后,我们还需要在每个切片上渲染标签,以便我们的用户可以理解每个切片代表什么。这是通过renderLabels函数完成的。

function renderLabels(pie, arc) {
  var labels = _pieG.selectAll("text.label")
    .data(pie(_data)); // <-E

  labels.enter()
    .append("text")
    .attr("class", "label");

  labels.transition()
    .attr("transform", function (d) {
      return "translate(" 
        + arc.centroid(d) + ")"; //<-F
      })
    .attr("dy", ".35em")
    .attr("text-anchor", "middle")
    .text(function (d) {
      return d.data.id;
    });
}

再次使用饼图布局作为数据来生成svg:text元素。标签的位置是通过arc.centroid(行 F)计算的。此外,标签位置通过过渡进行动画处理,这样它们就可以与弧一起移动。

更多...

饼图在许多不同的领域中被广泛使用。然而,由于它们对于人类眼睛来说难以比较给定饼图的各个部分,以及它们的信息密度低,因此它们也受到了广泛的批评。因此,强烈建议将部分数量限制在 3 个以下,其中 2 个是理想的。否则,你总是可以使用条形图或小型表格在精度和传达能力更好的地方替换饼图。

参见

  • 在第七章的使用弧生成器配方中,进入形状

  • 在第七章的实现弧过渡配方中,进入形状

构建堆叠面积图

在第八章的Creating an area chart食谱中,我们探讨了如何使用 D3 实现基本分层面积图。在本食谱中,我们将基于我们在面积图食谱中学到的知识来实现堆叠面积图。堆叠面积图是标准面积图的一种变体,其中不同的区域堆叠在一起,不仅使观众能够单独比较不同的数据系列,而且还能比较它们与总量的比例关系。

准备工作

在您的网页浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter9/stacked-area-chart.html.

如何实现...

本食谱是在我们在第八章中实现的Chart Them Up的基础上构建的,因此,在以下代码示例中,仅包含与堆叠面积图特别相关的部分:

<script type="text/javascript">
function stackedAreaChart() {
  var _chart = {};

  var _width = 900, _height = 450,
    _margins = {top: 30, left: 30, right: 30, bottom: 30},
    _x, _y,
    _data = [],
    _colors = d3.scale.category10(),
    _svg,
    _bodyG,
    _line;

  _chart.render = function () {
    if (!_svg) {
      _svg = d3.select("body").append("svg")
      .attr("height", _height)
      .attr("width", _width);

    renderAxes(_svg);

    defineBodyClip(_svg);
  }

  renderBody(_svg);
};
...
function renderBody(svg) {
  if (!_bodyG)
    _bodyG = svg.append("g")
      .attr("class", "body")
      .attr("transform", "translate("
        + xStart() + ","
        + yEnd() + ")")
      .attr("clip-path", "url(#body-clip)");

  var stack = d3.layout.stack() //<-A
    .offset('zero');
  stack(_data); //<-B

  renderLines(_data);

  renderAreas(_data);
}

function renderLines(stackedData) {
  // explained in details in the'how it works...' section
...
}

function renderAreas(stackedData) {
  // explained in details in the 'how it works...' section
...
}
...

本食谱生成了以下可视化效果:

如何实现...

堆叠面积图

工作原理...

与标准面积图相比,本食谱的主要区别在于堆叠。本食谱中展示的堆叠效果是通过在行 A 上创建的d3.layout.stack实现的。

var stack = d3.layout.stack() //<-A
  .offset('zero');
stack(_data); //<-B

在堆叠布局上,我们唯一进行的自定义是将其offset设置为zero。D3 堆叠布局支持几种不同的偏移模式,这些模式决定了使用哪种堆叠算法;这是我们将在本食谱和下一食谱中探讨的内容。在这种情况下,我们使用zero偏移堆叠,它生成一个零基线的堆叠算法,这正是本食谱所想要的。接下来,在行 B 上,我们对给定的数据数组调用了堆叠布局,生成了以下布局数据:

工作原理...

堆叠数据

如所示,堆叠布局自动为我们的三个不同数据系列中的每个数据项计算一个基线y0。现在,我们有了这个堆叠数据集,我们可以轻松地生成堆叠线。

function renderLines(stackedData) {
  _line = d3.svg.line()
    .x(function (d) {
      return _x(d.x); //<-C
    })
    .y(function (d) {
      return _y(d.y + d.y0); //<-D
    });
  _bodyG.selectAll("path.line")
    .data(stackedData)
    .enter()
    .append("path")
    .style("stroke", function (d, i) {
      return _colors(i);
    })
    .attr("class", "line");

  _bodyG.selectAll("path.line")
    .data(stackedData)
    .transition()
    .attr("d", function (d) {
      return _line(d);
    });
}

创建了一个 D3 线生成函数,其 x 值直接映射到x(行 C),其 y 值映射到y + y0(行 D)。这就是进行线堆叠所需做的全部工作。renderLines函数的其余部分基本上与基本面积图实现相同。面积堆叠逻辑略有不同:

function renderAreas(stackedData) {
  var area = d3.svg.area()
    .x(function (d) {
      return _x(d.x); //<-E
    })
    .y0(function(d){return _y(d.y0);}) //<-F
    .y1(function (d) {
      return _y(d.y + d.y0); //<-G
    });
  _bodyG.selectAll("path.area")
    .data(stackedData)
    .enter()
    .append("path")
    .style("fill", function (d, i) {
      return _colors(i);
    })
    .attr("class", "area");

  _bodyG.selectAll("path.area")
    .data(_data)
    .transition()
    .attr("d", function (d) {
      return area(d);
    });
}

在渲染面积时,与线渲染逻辑类似,我们唯一需要更改的地方是在d3.svg.area生成器设置中。对于面积,x值仍然直接映射到x(行 E),其y0直接映射到y0,最后y1yy0的和(行 G)。

如我们所见,D3 堆叠布局设计得非常好,可以与不同的 D3 SVG 生成函数兼容。因此,使用它来生成堆叠效果非常直接和方便。

更多内容...

让我们看看堆叠面积图的几个变体。

扩展面积图

我们提到d3.layout.stack支持不同的偏移模式。除了我们之前看到的zero偏移之外,还有一种对面积图非常有用的偏移模式,称为expand。在expand模式下,堆叠布局将不同层标准化以填充[0, 1]的范围。如果我们更改此食谱中的偏移模式和 y 轴域为[0, 1],我们将得到下面显示的扩展(标准化)面积图。

扩展面积图

扩展面积图

对于完整的配套代码示例,请访问:github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter9/expanded-area-chart.html

流图

另一种有趣的堆叠面积图变体被称为流图。流图是一种围绕中心轴显示的堆叠面积图,它创造出一个流动和有机的形状。流图最初由李·拜伦开发,并于 2008 年在《纽约时报》一篇关于电影票房收入的文章中普及。D3 堆叠布局内置了对这种堆叠算法的支持,因此将基于零的堆叠面积图转换为流图是微不足道的。关键区别在于流图使用wiggle作为其布局偏移模式。

流图

流图

对于完整的配套代码示例,请访问:github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter9/streamgraph.html

参考资料也

  • d3.layout.stack提供了一些额外的函数来定制其行为;有关堆叠布局的更多信息,请访问github.com/mbostock/d3/wiki/Stack-Layout

  • 在第八章“Chart Them Up”的创建面积图食谱中,第八章。

构建树状图

树状图是由本·施奈德曼在 1991 年提出的。树状图将层次树状数据显示为一系列递归划分的矩形。换句话说,它将树的每一分支显示为一个大的矩形,然后使用代表子分支的小矩形进行平铺。这个过程会一直重复,直到达到树的叶子。

注意

关于树状图的更多信息,请参阅本·施奈德曼在www.cs.umd.edu/hcil/treemap-history/上的这篇论文。

在我们深入代码示例之前,让我们首先定义一下我们所说的层次数据

到目前为止,我们已经学习了多种能够表示通常存储在一维或二维数组中的平面数据结构的可视化类型。在本章的剩余部分,我们将把我们的重点转向数据可视化中另一种常见的类型——层次数据结构。与平面数据结构中使用数组不同,层次数据通常以根树的形式组织。以下 JSON 文件展示了在数据可视化项目中可能会遇到的典型层次数据:

{
  "name": "flare",
  "children": [
  {
    "name": "analytics",
    "children": [
    {
      "name": "cluster",
      "children": [
        {"name": "AgglomerativeCluster", "size": 3938},
        {"name": "CommunityStructure", "size": 3812},
        {"name": "MergeEdge", "size": 743}
      ]
    },
    {
      "name": "graph",
      "children": [
        {"name": "BetweennessCentrality", "size": 3534},
        {"name": "LinkDistance", "size": 5731}
      ]
    },
    {
      "name": "optimization",
      "children": [
        {"name": "AspectRatioBanker", "size": 7074}
      ]
    }
  ]  
  ]
}

这是从 D3 社区中用于演示目的的流行层次数据集的一个简略版本。这些数据是从一个流行的基于 Flash 的数据可视化库 Flare 中提取的,该库由加州大学伯克利分校可视化实验室创建。它显示了库中不同包之间的大小和层次关系。

注意

查看官方 Flare 网站以获取有关项目的更多信息:flare.prefuse.org/

如我们很容易看到的,这个特定的 JSON 数据流的结构是一个典型的单链根树,每个节点有一个父节点和存储在children数组中的多个子节点。这是组织您的层次数据以便由 D3 层次布局消费的最自然方式。在本章的其余部分,我们将使用这个特定的数据集来探索 D3 提供的不同层次数据可视化技术。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter9/treemap.html

如何实现...

现在,让我们看看如何使用 D3 树状图布局来直观地表示这类层次数据。

function treemapChart() {
  var _chart = {};

  var _width = 1600, _height = 800,
    _colors = d3.scale.category20c(),
   _svg,
   _nodes,
   _x = d3.scale.linear().range([0, _width]),
   _y = d3.scale.linear().range([0, _height]),
   _valueAccessor = function (d) {
      return 1;
    },
  _bodyG;

  _chart.render = function () {
    if (!_svg) {
      _svg = d3.select("body").append("svg")
        .attr("height", _height)
        .attr("width", _width);
    }

    renderBody(_svg);
  };

  function renderBody(svg) {
    // explained in details in the 'how it works...' section
    ... 

    renderCells(cells);
  }

  function renderCells(cells){
    // explained in details in the 'how it works...' section
    ...
  }

  // accessors omitted
  ...

  return _chart;
}

d3.json("flare.json", function (nodes) {
  var chart = treemapChart();
  chart.nodes(nodes).render();
});

此配方生成了以下树状图可视化:

如何实现...

树状图

它是如何工作的...

到目前为止,你可能对实现如此复杂的数据可视化所需的代码如此之少感到惊讶。这是因为大部分繁重的工作都是由d3.layout.treemap完成的。

function renderBody(svg) {
  if (!_bodyG) {
    _bodyG = svg.append("g")
      .attr("class", "body");

      _treemap = d3.layout.treemap() //<-A
        .round(false)
        .size([_width, _height])
        .sticky(true);
      }

      _treemap.value(_valueAccessor); //<-B

  var nodes = _treemap.nodes(_nodes) //<-C
    .filter(function (d) {
      return !d.children; //<-D
    });

  var cells = svg.selectAll("g") //<-E
    .data(nodes);

  renderCells(cells);
    }

树状图布局在行 A 上定义,并包含一些基本的自定义设置:

  • round(false): 如果开启舍入,树状图布局将舍入到精确的像素边界。当你想要避免 SVG 中的抗锯齿伪影时,这非常有用。

  • size([_width, _height]): 它将布局边界设置为 SVG 的大小。

  • sticky(true): 在粘性模式下,树状图布局将尝试在过渡过程中保持节点(在我们的例子中是矩形)的相对排列。

  • value(_valueAccessor): 此配方提供的一项功能是能够在运行时切换树状图值访问器。值访问器用于树状图访问每个节点的值字段。在我们的例子中,它可以是以下函数之一:

    function(d){ return d.size; } // visualize package size
    function(d){ return 1; } // visualize package count
    
  • 要在 Flare JSON 数据源上应用树状图布局,我们只需将树状图布局中的nodes设置为我们的 JSON 树中的根节点(行 C)。然后,在行 D 中进一步过滤树状图节点以删除父节点(有子节点的节点),因为我们只想可视化叶子节点,同时使用着色来突出显示此树状图实现中的包分组。树状图布局生成的布局数据包含以下结构:工作原理...

    树状图节点对象

如所示,树状图布局已使用其算法为每个节点标注和计算了许多属性。其中许多属性在可视化时非常有用,在本教程中,我们主要关注以下属性:

  • x: 单元 x 坐标

  • y: 单元 y 坐标

  • dx: 单元宽度

  • dy: 单元高度

在行 E 中,为给定的节点创建了一组svg:g元素。然后,renderCells函数负责创建矩形及其标签:

function renderCells(cells){
  var cellEnter = cells.enter().append("g")
    .attr("class", "cell");

  cellEnter.append("rect")
  cellEnter.append("text");

  cells.transition().attr("transform", function (d) {
    return "translate("+d.x+","+d.y+")"; //<-F
  })
  .select("rect")
    .attr("width", function (d) {return d.dx - 1;})
    .attr("height", function (d) {return d.dy - 1;})
    .style("fill", function (d) {
      return _colors(d.parent.name); //<-G
    });

  cells.select("text") //<-H
    .attr("x", function (d) {return d.dx / 2;})
    .attr("y", function (d) {return d.dy / 2;})
    .attr("dy", ".35em")
    .attr("text-anchor", "middle")
    .text(function (d) {return d.name;})
    .style("opacity", function (d) {
      d.w = this.getComputedTextLength();
      return d.dx > d.w ? 1 : 0; //<-I
    );

  cells.exit().remove();
}

每个矩形放置在其位置(x, y)上,该位置由行 F 上的布局确定,然后其宽度和高度设置为dxdy。在行 G 中,我们使用每个单元的父节点名称着色每个单元,从而确保属于同一父节点的所有子节点都以相同的方式着色。从行 H 开始,我们为每个矩形创建标签(svg:text)元素,并将其文本设置为节点名称。这里值得提到的一个方面是,为了避免显示比单元格宽度更大的标签,如果标签比单元格宽度大,则将标签的不透明度设置为 0(行 I)。

小贴士

技术 - 自动隐藏标签

在行 I 中,我们看到的是可视化中实现自动隐藏标签的有用技术。这项技术可以一般地考虑以下形式:

.style("opacity", function (d) {
    d.w = this.getComputedTextLength();
    return d.dx > d.w ? 1 : 0;
)

相关内容

构建树

当处理分层数据结构时,树(树图)可能是最自然和常见的可视化方式之一,通常用于展示不同数据元素之间的结构依赖关系。树是一个无向图,其中任意两个节点(顶点)通过一条且仅有一条简单路径连接。在本教程中,我们将学习如何使用树布局实现树可视化。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter9/tree.html

如何操作...

现在让我们看看d3.layout.tree的实际应用:

function tree() {
  var _chart = {};

  var _width = 1600, _height = 800,
    _margins = {top:30, left:120, right:30, bottom:30},
    _svg,
    _nodes,
    _i = 0,
    _tree,
    _diagonal,
    _bodyG;

  _chart.render = function () {
    if (!_svg) {
      _svg = d3.select("body").append("svg")
        .attr("height", _height)
        .attr("width", _width);
    }

    renderBody(_svg);
  };

  function renderBody(svg) {
    if (!_bodyG) {
      _bodyG = svg.append("g")
        .attr("class", "body")
        .attr("transform", function (d) {
          return "translate(" + _margins.left 
            + "," + _margins.top + ")";
          });
    }

    _tree = d3.layout.tree()
      .size([
        (_height - _margins.top - _margins.bottom), 
        (_width - _margins.left - _margins.right)
      ]);

    _diagonal = d3.svg.diagonal()
      .projection(function (d) {
        return [d.y, d.x];
      });

    _nodes.x0 = (_height-_margins.top-_margins.bottom) / 2;
    _nodes.y0 = 0;

    render(_nodes);
  }

  function render(source) {
    var nodes = _tree.nodes(_nodes);

    renderNodes(nodes, source);

    renderLinks(nodes, source);
  }

  function renderNodes(nodes, source) {
    // will be explained in the 'how it works...' section
    ...
  }

  function renderLinks(nodes, source) {
    // will be explained in the 'how it works...' section
    ...
  }

  // accessors omitted
  ...

  return _chart;
}

本教程生成的以下树状图可视化:

如何操作...

工作原理...

如我们之前提到的,这个食谱是在 D3 树布局之上构建的。d3.layout.tree是专门设计用来将层次数据结构转换为适合生成树图的视觉布局数据的。我们的树布局实例定义如下:

_tree = d3.layout.tree()
  .size([
    (_height - _margins.top - _margins.bottom), 
    (_width - _margins.left - _margins.right)
  ]);

我们在这里提供的唯一设置是我们可视化的尺寸,即我们的 SVG 图像尺寸减去边距。然后d3.layout.tree将处理其余部分,并相应地计算每个节点的位置。要使用树布局,你只需调用其nodes函数。

var nodes = _tree.nodes(_nodes);

如果你查看nodes布局数据,它包含看起来像这样的节点数据:

如何工作...

树布局数据

在这个食谱中,我们需要提到的一个新的 D3 SVG 形状生成器是d3.svg.diagonal。对角线生成器旨在创建连接两个点的svg:path。在这个食谱中,我们使用对角线生成器与树布局的links函数一起生成连接树中每个节点的路径。

_diagonal = d3.svg.diagonal()
  .projection(function (d) {
    return [d.y, d.x];
  });

在这种情况下,我们配置对角线生成器使用笛卡尔方向投影,并简单地依赖于树布局计算出的 x 和 y 坐标进行定位。实际的渲染由以下函数执行。首先让我们看看renderNodes函数:

function renderNodes(nodes, source) {
  nodes.forEach(function (d) {
    d.y = d.depth * 180; 
  });

在这里,我们遍历所有节点,并人为地在它们之间分配 180 像素的间距。你可能想知道为什么我们使用 y 坐标而不是 x 坐标。原因是,在这个食谱中,我们想要渲染一个水平树而不是垂直树;因此,我们必须在这里反转 x 和 y 坐标。

  var node = _bodyG.selectAll("g.node")
    .data(nodes, function (d) {
      return d.id || (d.id = ++_i);
    });

现在我们将树布局生成的节点绑定到数据,以生成树节点元素。在这个时候,我们还使用索引为每个节点分配一个 ID,以获得对象一致性。

  var nodeEnter = node.enter().append("svg:g")
    .attr("class", "node")
    .attr("transform", function (d) {
      return "translate(" + source.y0 
        + "," + source.x0 + ")";
    });

在这一点上,我们创建节点并将它们移动到在renderBody函数中设置的相同原点。

  nodeEnter.append("svg:circle")
    .attr("r", 1e-6);

  var nodeUpdate = node.transition()
    .attr("transform", function (d) {
      return "translate(" + d.y + "," + d.x + ")";
    });

  nodeUpdate.select("circle")
    .attr("r", 4.5);

现在我们开始在更新部分开始一个转换,将节点移动到它们正确的位置。

  var nodeExit = node.exit().transition()
    .attr("transform", function (d) {
      return "translate(" + source.y 
        + "," + source.x + ")";
      })
    .remove();

  nodeExit.select("circle")
    .attr("r", 1e-6);

  renderLabels(nodeEnter, nodeUpdate, nodeExit);
}

最后,我们处理退出情况,并在简短的折叠效果动画后移除节点。renderLabels函数相当简单,所以我们不会在这里详细说明。请参阅完整的在线代码伴侣以获取详细信息。

现在让我们看看更有趣的renderLinks函数。

function renderLinks(nodes, source) {
  var link = _bodyG.selectAll("path.link")
    .data(_tree.links(nodes), function (d) {
      return d.target.id;
    });

首先,我们使用d3.layout.tree上的links函数生成数据绑定。links函数返回一个包含指向适当树节点的{source, target}字段的链接对象数组。

  link.enter().insert("svg:path", "g")
    .attr("class", "link")
    .attr("d", function (d) {
      var o = {x: source.x0, y: source.y0};
      return _diagonal({source: o, target: o});
    });

enter 部分中,创建了 svg:path 元素来直观地表示源节点和目标节点之间的链接。为了生成路径元素的 d 属性,我们依赖于我们之前定义的 d3.svg.diagonal 生成器。在创建过程中,我们暂时将链接设置为长度为零的路径,通过将源和目标都设置为同一点来设置。因此,当我们后来将链接过渡到其适当长度时,它将生成扩展效果。

  link.transition()
    .attr("d", _diagonal);

现在我们使用树布局生成的链接数据,将链接过渡到其适当的长度和位置。

  link.exit().transition()
    .attr("d", function (d) {
      var o = {x: source.x, y: source.y};
      return _diagonal({source: o, target: o});
    })
  .remove();

当我们再次移除节点时,我们依靠同样的技巧,将链接设置为长度为零的父节点位置,以模拟折叠效果。

参考以下内容

构建封装图

封装图是一种有趣的层次数据结构的可视化,它使用递归圆形封装算法。它使用包含(嵌套)来表示层次结构。对于数据树中的每个叶节点,都会创建一个圆,其大小与每个数据元素的一个特定定量维度成比例。在这个菜谱中,我们将学习如何使用 D3 封装布局实现这种可视化。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter9/pack.html

如何做...

在这个菜谱中,让我们看看如何使用 d3.layout.pack 实现一个封装图。

function pack() {
  var _chart = {};

  var _width = 1280, _height = 800,
    _svg,
    _r = 720,
    _x = d3.scale.linear().range([0, _r]),
    _y = d3.scale.linear().range([0, _r]),
    _nodes,
    _bodyG;

  _chart.render = function () {
    if (!_svg) {
      _svg = d3.select("body").append("svg")
        .attr("height", _height)
        .attr("width", _width);
    }

    renderBody(_svg);
  };

  function renderBody(svg) {
    if (!_bodyG) {
      _bodyG = svg.append("g")
        .attr("class", "body")
        .attr("transform", function (d) {
          return "translate(" 
            + (_width - _r) / 2 + "," 
            + (_height - _r) / 2 
            + ")";
        });
    }

    var pack = d3.layout.pack()
      .size([_r, _r])
      .value(function (d) {
        return d.size;
      });

    var nodes = pack.nodes(_nodes);

    renderCircles(nodes);

    renderLabels(nodes);
  }

  function renderCircles(nodes) {
    // will be explained in the 'how it works...' section
    ...
  }

  function renderLabels(nodes) {
    // omitted
    ...
  }

  // accessors omitted
  ...

  return _chart;
}

这个菜谱生成了以下可视化:

如何做...

封装图

工作原理...

在这个菜谱中,我们首先需要关注的是定义我们的布局;在这种情况下,我们需要使用 d3.layout.pack 布局。

var pack = d3.layout.pack()
  .size([_r, _r])
  .value(function (d) {
    return d.size;
  });

var nodes = pack.nodes(_nodes);

现在我们使用外圆的半径设置布局的大小,并将值设置为使用 Flare 包的大小,这反过来将决定每个圆的大小;因此,有效地使每个圆的大小与我们的数据源中的包大小成比例。一旦布局创建完成,我们就通过其nodes函数将我们的数据元素传递进去,生成具有以下结构的布局数据:

如何工作...

打包布局数据

圆形渲染是在renderCircle函数中完成的:

function renderCircles(nodes) {
  var circles = _bodyG.selectAll("circle")
    .data(nodes);

  circles.enter().append("svg:circle");

然后,我们简单地绑定布局数据,并为每个节点创建svg:circle元素。

  circles.transition()
    .attr("class", function (d) {
      return d.children ? "parent" : "child";
    })
    .attr("cx", function (d) {return d.x; })
    .attr("cy", function (d) {return d.y; })
    .attr("r", function (d) {return d.r; });

对于更新,我们将cxcyradius设置为打包布局为我们每个圆计算出的值。

  circles.exit().transition()
    .attr("r", 0)
    .remove();
}

最后,在移除圆之前,我们首先将圆的大小减小到零,然后再移除它们以生成更平滑的过渡。在这个配方中,标签渲染相当直接,得益于我们在本章中引入的自动隐藏技术,因此我们不会在这里详细说明该函数。

参见

第十章. 与你的可视化交互

在本章中,我们将涵盖:

  • 与鼠标交互

  • 与多点触控设备交互

  • 实现缩放和平移行为

  • 实现拖拽行为

简介

可视化设计的最终目标是优化应用程序,以便它们能帮助我们更有效地完成认知工作。

Ware C. (2012)

数据可视化的目标是帮助观众通过隐喻、心智模型对齐和认知放大,快速有效地从大量原始数据中获取信息。到目前为止,在这本书中,我们已经介绍了各种技术,利用 D3 库实现多种类型的可视化。然而,我们还没有触及可视化的一个关键方面:人机交互。各种研究已经得出结论,人机交互在信息可视化中具有独特的价值。

将可视化与计算引导相结合,可以更快地分析更复杂的场景...本案例研究充分证明了复杂模型与引导和交互式可视化之间的相互作用可以扩展建模的应用范围,而不仅仅是研究。

Barrass I. & Leng J (2011)

在本章中,我们将专注于 D3 的人机可视化交互支持,或者如前所述,学习如何将计算引导能力添加到你的可视化中。

与鼠标事件交互

鼠标是大多数桌面和笔记本电脑上最常见和最受欢迎的人机交互控制。即使今天,随着多点触控设备逐渐占据主导地位,触摸事件通常仍然被模拟成鼠标事件;因此,使设计用于通过鼠标交互的应用程序可以通过触摸使用。在本食谱中,我们将学习如何处理 D3 中的标准鼠标事件。

准备工作

在你的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter10/mouse.html

如何做到这一点...

在以下代码示例中,我们将探讨在 D3 中注册和处理鼠标事件的技术。尽管在这个特定的例子中我们只处理了clickmousemove,但这里使用的技术可以轻松应用于现代浏览器支持的所有其他标准鼠标事件:

<script type="text/javascript">
    var r = 400;

    var svg = d3.select("body")
            .append("svg");

    var positionLabel = svg.append("text")
            .attr("x", 10)
            .attr("y", 30);

    svg.on("mousemove", function () { //<-A
        printPosition();
    });

    function printPosition() { //<-B
        var position = d3.mouse(svg.node()); //<-C
        positionLabel.text(position);
    }  

    svg.on("click", function () { //<-D
        for (var i = 1; i < 5; ++i) {
            var position = d3.mouse(svg.node());

            var circle = svg.append("circle")
                    .attr("cx", position[0])
                    .attr("cy", position[1])
                    .attr("r", 0)
                    .style("stroke-width", 5 / (i))
                    .transition()
                        .delay(Math.pow(i, 2.5) * 50)
                        .duration(2000)
                        .ease('quad-in')
                    .attr("r", r)
                    .style("stroke-opacity", 0)
                    .each("end", function () {
                        d3.select(this).remove();
                    });
        }
    });
</script>

本食谱生成以下交互式可视化:

如何做到这一点...

鼠标交互

它是如何工作的...

在 D3 中,要注册事件监听器,我们需要在特定选择上调用on函数。给定的事件监听器将被附加到所有选定的元素上,用于指定的事件(行 A)。本食谱中的以下代码附加了一个mousemove事件监听器,用于显示当前鼠标位置(行 B):

svg.on("mousemove", function () { //<-A
    printPosition();
});

function printPosition() { //<-B
    var position = d3.mouse(svg.node()); //<-C
    positionLabel.text(position);
}  

在第 C 行,我们使用了d3.mouse函数来获取相对于给定容器元素的当前鼠标位置。此函数返回一个包含两个元素的数组[x, y]。之后,我们也在第 D 行使用相同的on函数注册了对鼠标click事件的监听器:

svg.on("click", function () { //<-D
        for (var i = 1; i < 5; ++i) {
            var position = d3.mouse(svg.node());

        var circle = svg.append("circle")
                .attr("cx", position[0])
                .attr("cy", position[1])
                .attr("r", 0)
                .style("stroke-width", 5 / (i)) // <-E
                .transition()
                    .delay(Math.pow(i, 2.5) * 50) // <-F
                    .duration(2000)
                    .ease('quad-in')
                .attr("r", r)
                .style("stroke-opacity", 0)
                .each("end", function () {
                    d3.select(this).remove(); // <-G
                });
        }
});

再次,我们使用d3.mouse函数检索当前鼠标位置,然后生成五个同心扩大的圆来模拟涟漪效果。涟漪效果是通过几何级数增加的延迟(第 F 行)和减少的stroke-width(第 E 行)来模拟的。最后,当过渡效果完成后,使用过渡end监听器(第 G 行)移除圆圈。如果您不熟悉这种类型的过渡控制,请查阅第六章, 以风格进行转换,以获取更多详细信息。

还有更多...

虽然我们在此食谱中只演示了监听clickmousemove事件,但您可以通过on函数监听浏览器支持的任何事件。以下是在构建交互式可视化时需要了解的有用鼠标事件列表:

  • click: 当用户点击鼠标按钮时触发

  • dbclick: 当鼠标按钮被连续点击两次时触发

  • mousedown: 当鼠标按钮被按下时触发

  • mouseenter: 当鼠标移动到元素或其子元素边界时触发

  • mouseleave: 当鼠标移动出元素及其所有子元素的边界时触发

  • mousemove: 当鼠标移动到元素上时触发

  • mouseout: 当鼠标移动出元素边界时触发

  • mouseover: 当鼠标移动到元素边界时触发

  • mouseup: 当鼠标按钮在元素上释放时触发

参考内容

与多触点设备交互

现在,随着多触点设备的普及,任何面向大众消费的可视化都需要考虑其交互性,不仅通过传统的指向设备,还要通过多触点和手势。在本食谱中,我们将探索 D3 提供的触摸支持,看看它如何被利用来生成一些非常有趣的与多触点设备交互。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter10/touch.html.

如何做...

在这个菜谱中,我们将在用户触摸周围生成一个进度圆,一旦进度完成,则会在圆周围触发后续的波纹效果。然而,如果用户提前结束触摸,则我们应停止进度圆而不生成波纹:

<script type="text/javascript">
    var initR = 100, 
        r = 400, 
        thickness = 20;

    var svg = d3.select("body")
            .append("svg");

    d3.select("body")
            .on("touchstart", touch)
            .on("touchend", touch);

    function touch() {
        d3.event.preventDefault();

        var arc = d3.svg.arc()
                .outerRadius(initR)
                .innerRadius(initR - thickness);

        var g = svg.selectAll("g.touch")
                .data(d3.touches(svg.node()), function (d) {
                    return d.identifier;
                });

        g.enter()
            .append("g")
            .attr("class", "touch")
            .attr("transform", function (d) {
                return "translate(" + d[0] + "," + d[1] + ")";
            })
            .append("path")
                .attr("class", "arc")
                .transition().duration(2000)
                .attrTween("d", function (d) {
                    var interpolate = d3.interpolate(
                            {startAngle: 0, endAngle: 0},
                            {startAngle: 0, endAngle: 2 * Math.PI}
                        );
                    return function (t) {
                        return arc(interpolate(t));
                    };
                })
                .each("end", function (d) {
                    if (complete(g))
                        ripples(d);
                    g.remove();
                });

        g.exit().remove().each(function () {
            this.__stopped__ = true;
        });
    }

    function complete(g) {
        return g.node().__stopped__ != true;
    }

    function ripples(position) {
        for (var i = 1; i < 5; ++i) {
            var circle = svg.append("circle")
                    .attr("cx", position[0])
                    .attr("cy", position[1])
                    .attr("r", initR - (thickness / 2))
                    .style("stroke-width", thickness / (i))
                .transition().delay(Math.pow(i, 2.5) * 50).duration(2000).ease('quad-in')
                    .attr("r", r)
                    .style("stroke-opacity", 0)
                    .each("end", function () {
                        d3.select(this).remove();
                    });
        }
    }
</script>

此菜谱在触摸设备上生成以下交互式可视化:

如何做...

触摸交互

它是如何工作的...

通过 D3 选择器的on函数注册触摸事件监听器,类似于我们在前一个菜谱中处理鼠标事件的方式:

d3.select("body")
            .on("touchstart", touch)
            .on("touchend", touch);

这里的一个关键区别是我们将触摸事件监听器注册在body元素上而不是svg元素上,因为在许多操作系统和浏览器中定义了默认的触摸行为,我们希望用我们的自定义实现来覆盖它。这是通过以下函数调用来完成的:

d3.event.preventDefault();

一旦触发触摸事件,我们使用d3.touches函数检索多个触摸点数据,如下面的代码片段所示:

var g = svg.selectAll("g.touch")
    .data(d3.touches(svg.node()), function (d) {
        return d.identifier;
    }); 

d3.mouse函数返回的二维数组不同,d3.touches返回一个二维数组的数组,因为每个触摸事件可能有多个触摸点。每个触摸位置数组的数据结构如下所示:

它是如何工作的...

触摸位置数组

除了触摸点的[x, y]位置外,每个位置数组还携带一个标识符,以帮助您区分每个触摸点。我们在此菜谱中使用此标识符来建立对象恒常性。一旦触摸数据绑定到选择,就会为每个触摸点在用户手指周围生成进度圆:

        g.enter()
            .append("g")
            .attr("class", "touch")
            .attr("transform", function (d) {
                return "translate(" + d[0] + "," + d[1] + ")";
            })
            .append("path")
                .attr("class", "arc")
                .transition().duration(2000).ease('linear')
                .attrTween("d", function (d) { // <-A
                    var interpolate = d3.interpolate(
                            {startAngle: 0, endAngle: 0},
                            {startAngle: 0, endAngle: 2 * Math.PI}
                        );
                    return function (t) {
                        return arc(interpolate(t));
                    };
                })
                .each("end", function (d) { // <-B
                    if (complete(g))
                        ripples(d);
                    g.remove();
                });

这是通过标准弧形过渡和属性插值(行 A)来完成的,正如在第七章“进入形状”中所述。一旦过渡完成,如果进度圆尚未被用户取消,则在线 B 上生成类似于我们在前一个菜谱中所做的波纹效果。由于我们在touchstarttouchend事件上注册了相同的touch事件监听器,我们可以使用以下行来移除进度圆并设置一个标志来指示此进度圆已被提前停止:

        g.exit().remove().each(function () {
            this.__stopped__ = true;
        });

我们需要设置这个状态标志,因为没有方法可以取消已经开始的过渡;因此,即使在从 DOM 树中移除进度圆元素之后,过渡仍然会完成并触发行 B。

还有更多...

我们已经通过 touchstarttouchend 事件演示了触摸交互;然而,你可以使用相同的模式来处理浏览器支持的任何其他触摸事件。以下列表包含了 W3C 建议的触摸事件类型:

  • touchstart:当用户在触摸表面上放置一个触摸点时触发

  • touchend:当用户从触摸表面移除一个触摸点时触发

  • touchmove:当用户在触摸表面上移动一个触摸点时触发

  • touchcancel:当触摸点以特定方式被干扰时触发

相关内容

  • 第六章,以风格进行过渡,了解更多关于在此食谱中使用对象恒常性和涟漪效果技术

  • 第七章,进入形状,了解更多关于在此食谱中使用进度圆环属性缓动过渡技术

  • W3C 触摸事件提出了触摸事件类型的完整列表建议:www.w3.org/TR/touch-events/

  • d3.touch API 文档,了解更多关于多指检测的详细信息:github.com/mbostock/d3/wiki/Selections#wiki-d3_touches

实现缩放和平移行为

缩放和平移是数据可视化中常见且有用的技术,与基于 SVG 的可视化结合得非常好,因为矢量图形不像位图那样会像素化。缩放在处理大型数据集时特别有用,当无法或不可能可视化整个数据集时,因此需要采用缩放和钻取的方法。在这个食谱中,我们将探索 D3 内置的缩放和平移支持。

准备工作

在你的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter10/zoom.html.

如何做...

在这个食谱中,我们将使用 D3 的缩放支持来实现几何缩放和平移。让我们看看代码中是如何实现的:

<script type="text/javascript">
    var width = 960, height = 500, r = 50;

    var data = [
        [width / 2 - r, height / 2 - r],
        [width / 2 - r, height / 2 + r],
        [width / 2 + r, height / 2 - r],
        [width / 2 + r, height / 2 + r]
    ];

    var svg = d3.select("body").append("svg")
            .attr("width", width)
            .attr("height", height)
            .call(
                d3.behavior.zoom()
                    .scaleExtent([1, 10])
                    .on("zoom", zoom)
            )
            .append("g");

    svg.selectAll("circle")
            .data(data)
            .enter().append("circle")
            .attr("r", r)
            .attr("transform", function (d) {
                return "translate(" + d + ")";
            });

    function zoom() {
        svg.attr("transform", "translate(" 
            + d3.event.translate 
+ ")scale(" + d3.event.scale + ")");
    }
</script>

此食谱生成了以下缩放和平移效果:

如何做...

原始

如何做...

Zoom

如何做...

平移

它是如何工作的...

在这一点上,你可能会对使用 D3 实现这个完全功能的缩放和平移效果所需的代码如此之少而感到惊讶。如果你在浏览器中打开了这份食谱,你也会注意到缩放和平移对鼠标滚轮和多指手势都反应得非常好。大部分的重活都是由 D3 库完成的。我们在这里需要做的就是简单地定义缩放行为。让我们看看代码中是如何实现的。首先,我们需要在 SVG 容器上定义缩放行为:

var svg = d3.select("body").append("svg")
            .attr("style", "1px solid black")
            .attr("width", width)
            .attr("height", height)
            .call( // <-A
                d3.behavior.zoom() // <-B
                    .scaleExtent([1, 10]) // <-C
                    .on("zoom", zoom) // <-D
            )
            .append("g");

正如我们在行 A 中看到的,创建了一个 d3.behavior.zoom 函数(行 B),并在 svg 容器上调用它。d3.behavior.zoom 将自动创建事件监听器来处理关联 SVG 容器(在我们的情况下是 svg 元素本身)上的低级缩放和平移手势。低级缩放手势随后将被转换为高级 D3 缩放事件。默认事件监听器支持鼠标和触摸事件。在行 C 中,我们使用一个包含两个元素 [1, 10] 的数组定义 scaleExtent(一个范围)。缩放范围定义了允许缩放的程度(在我们的情况下我们允许 10 倍缩放)。最后,在行 D 中,我们注册了一个自定义的缩放事件处理器来处理 D3 缩放事件。现在,让我们看看这个缩放事件处理器执行了哪些任务:

function zoom() {
        svg.attr("transform", "translate(" 
            + d3.event.translate 
            + ")scale(" + d3.event.scale + ")");
}

zoom 函数中,我们只是简单地将实际的缩放和平移委托给 SVG 变换。为了进一步简化这个任务,D3 缩放事件也计算了必要的平移和缩放。因此,我们所需做的就是将它们嵌入到 SVG 变换属性中。以下是缩放事件中包含的属性:

  • scale:表示当前缩放比例的数字

  • translate:表示当前平移向量的二维数组

到目前为止,你可能想知道拥有这个缩放函数的意义何在。为什么 D3 不能为我们处理这一步骤?原因是 D3 缩放行为并不是专门为 SVG 设计的,而是作为一个通用的缩放行为支持机制设计的。因此,这个缩放函数实现了将通用缩放和平移事件转换为 SVG 特定变换。

还有更多...

缩放函数还能够执行除了简单的坐标系变换之外的其他任务。例如,一个常见的技巧是在用户发出缩放手势时加载额外的数据,从而在缩放函数中实现钻取功能。一个著名的例子是数字地图;当你增加地图的缩放级别时,更多的数据和细节可以被加载并展示出来。

参考信息

实现拖动行为

本章我们将探讨的另一个常见交互式可视化行为是 拖动。拖动在可视化中非常有用,它允许通过力量提供图形重新排列或甚至通过用户输入实现,这些内容我们将在下一章讨论。在本食谱中,我们将探索拖动行为在 D3 中的支持方式。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter10/drag.html.

如何操作...

在这里,我们将生成四个可以使用 D3 拖动行为支持拖动的圆圈,并且还带有 SVG 边界检测。现在,让我们看看如何在代码中实现它:

<script type="text/javascript">
    var width = 960, height = 500, r = 50;

    var data = [
        [width / 2 - r, height / 2 - r],
        [width / 2 - r, height / 2 + r],
        [width / 2 + r, height / 2 - r],
        [width / 2 + r, height / 2 + r]
    ];

    var svg = d3.select("body").append("svg")
            .attr("width", width)
            .attr("height", height)
            .append("g");

    var drag = d3.behavior.drag()
            .on("drag", move);

    svg.selectAll("circle")
            .data(data)
            .enter().append("circle")
            .attr("r", r)
            .attr("transform", function (d) {
                return "translate(" + d + ")";
            })
            .call(drag);

    function move(d) {
        var x = d3.event.x, 
            y = d3.event.y;

        if(inBoundaries(x, y))
            d3.select(this) 
                .attr("transform", function (d) {
                    return "translate(" + x + ", " + y + ")";
                });
    }

    function inBoundaries(x, y){
        return (x >= (0 + r) && x <= (width - r)) 
            && (y >= (0 + r) && y <= (height - r));
    }
</script>

这个菜谱在以下四个圆圈上生成拖动行为:

如何操作...

原文

如何操作...

被拖动

它是如何工作的...

如我们所见,与 D3 缩放支持类似,拖动支持遵循类似的模式。主要的拖动能力由 d3.behavior.drag 函数(行 A)提供。D3 拖动行为自动创建适当的低级事件监听器来处理给定元素上的拖动手势,然后将低级事件转换为高级的 D3 拖动事件。支持鼠标和触摸事件:

var drag = d3.behavior.drag() // <-A
            .on("drag", move);

在这个菜谱中,我们关注的是 drag 事件,它由我们的 move 函数处理。与缩放行为类似,D3 拖动行为支持是事件驱动的,因此允许在实现中具有最大的灵活性,不仅支持 SVG,还支持 HTML5 画布。一旦定义,该行为可以通过在给定的选择上调用它来附加到任何元素:

svg.selectAll("circle")
            .data(data)
            .enter().append("circle")
            .attr("r", r)
            .attr("transform", function (d) {
                return "translate(" + d + ")";
            })
            .call(drag); // <-B

接下来,在 move 函数中,我们简单地使用 SVG 变换来将拖动的元素移动到正确的位置(行 D),根据拖动事件(行 C)传达的信息:

   function move(d) {
        var x = d3.event.x, // <-C
            y = d3.event.y;

        if(inBoundaries(x, y))
            d3.select(this) 
                .attr("transform", function (d) { // <-D
                    return "translate(" + x + ", " + y + ")";
                });
}

我们在这里检查的一个额外条件是计算 SVG 边界约束,以确保用户不能将元素拖动到 SVG 之外。这是通过以下检查实现的:

    function inBoundaries(x, y){
        return (x >= (0 + r) && x <= (width - r)) 
            && (y >= (0 + r) && y <= (height - r));
}

还有更多...

除了拖动事件,D3 拖动行为还支持两种其他事件类型。以下列表显示了所有支持的拖动事件类型及其属性:

  • dragstart: 当拖动手势开始时触发。

  • drag: 当元素被拖动时触发。d3.event 将包含 xy 属性,代表元素的当前绝对拖动坐标。它还将包含 dxdy 属性,代表元素相对于手势开始时的位置的坐标。

  • dragend: 当拖动手势完成时触发。

参见

第十一章。使用力

在本章中,我们将介绍以下内容:

  • 使用重力和电荷

  • 生成动量

  • 设置链接约束

  • 使用力辅助可视化

  • 操作力

  • 构建力导向图

简介

用力,卢克!

大师对学徒的智慧之言

在本章中,我们将介绍 D3 中最迷人的方面之一:力。力模拟是您可以添加到可视化中的最具震撼力的技术之一。通过一系列高度交互和完全功能性的示例,我们将帮助您探索 D3 力的典型应用(例如,力导向图),以及力操作的其他基本方面。

D3 力模拟支持并非作为一个独立的功能,而是一个额外的 D3 布局。正如我们在第九章中提到的,“布局”,D3 布局是非视觉数据导向的布局管理程序,旨在与不同的可视化一起使用。力布局最初是为了实现一种特定的可视化类型——力导向图而创建的。其实施使用基于标准 verlet 集成的粒子运动模拟,并支持简单的约束。

换句话说,D3 实现了一种数值方法,能够在粒子层面上松散地模拟牛顿的运动方程,并以简单的约束作为粒子之间的链接进行模拟。当然,这种布局在实现力导向图时是理想的;然而,通过本章中的配方,我们还将发现力布局能够生成许多其他有趣的可视化效果,这得益于其在自定义力操作方面的灵活性。本章介绍的技术应用甚至超出了数据可视化领域,并在许多其他领域有实际应用,例如用户界面设计。当然,我们还将介绍力布局的经典应用:本章中的力导向图。

使用重力和电荷

在本配方中,我们将向您介绍前两个基本力:重力和电荷。正如我们之前提到的,力布局设计的目的是松散地模拟牛顿的粒子运动方程,而这种模拟的一个主要特征就是电荷力。此外,力布局还实现了伪重力或更准确地说是一种通常以 SVG 为中心的弱几何约束,可以利用它来防止您的可视化逃离 SVG 画布。在以下示例中,我们将学习如何利用这两种基本且有时相反的力,通过粒子系统生成各种效果。

准备工作

在您的网页浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter11/gravity-and-charge.html.

如何做...

在以下示例中,我们将实验力布局的重力和电荷设置,以便您更好地理解涉及的不同对抗力及其相互作用:

<script type="text/javascript">
    var w = 1280, h = 800,
        force = d3.layout.force()
            .size([w ,h])
            .gravity(0)
            .charge(0)
            .friction(0.7);

    var svg = d3.select("body")
        .append("svg")
            .attr("width", w)
            .attr("height", h);

    force.on("tick", function () {
        svg.selectAll("circle")
            .attr("cx", function (d) {return d.x;})
            .attr("cy", function (d) {return d.y;});
    });

    svg.on("mousemove", function () {
        var point = d3.mouse(this),
            node = {x: point[0], y: point[1]}; // <-A

        svg.append("circle")
                .data([node])
            .attr("class", "node")
            .attr("cx", function (d) {return d.x;})
            .attr("cy", function (d) {return d.y;})
            .attr("r", 1e-6)
        .transition()
            .attr("r", 4.5)
        .transition()
            .delay(7000)
            .attr("r", 1e-6)
            .each("end", function () {
                force.nodes().shift(); // <-B
            })
            .remove();

        force.nodes().push(node); // <-C
        force.start(); // <-D
    });

    function changeForce(charge, gravity) {
        force.charge(charge).gravity(gravity);
    }
</script>

<div class="control-group">
    <button onclick="changeForce(0, 0)">
        No Force
    </button>
    <button onclick="changeForce(-60, 0)">
        Mutual Repulsion
    </button>
    <button onclick="changeForce(60, 0)">
        Mutual Attraction
    </button>
    <button onclick="changeForce(0, 0.02)">
        Gravity
    </button>
    <button onclick="changeForce(-30, 0.1)">
        Gravity with Repulsion
    </button>    
</div>

这个配方生成一个具有力功能的粒子系统,能够在以下图中显示的模式下运行:

如何做...

力模拟模式

它是如何工作的...

在我们动手编写前面的代码示例之前,让我们先深入探讨一下重力、电荷和摩擦的概念,这样我们就能更容易地理解在这个配方中我们将使用到的所有神奇数字设置。

电荷

电荷被指定来模拟粒子之间的相互 n 体力。负值导致节点相互排斥,而正值导致节点相互吸引。电荷的默认值是-30。电荷值也可以是一个函数,该函数将在力模拟开始时为每个节点进行评估。

重力

力布局中的重力模拟并不是为了模拟物理重力,这可以通过使用正电荷来模拟。相反,它被实现为一个类似于虚拟弹簧的弱几何约束,连接到布局的每个节点。默认的重力强度设置为0.1。随着节点远离中心,重力强度以线性比例增加,而接近布局中心时,重力强度几乎为零。因此,重力将在某个时刻克服排斥电荷,从而防止节点逃离布局。

摩擦

D3 力布局中的摩擦并不代表标准的物理摩擦系数,而是实现为一个速度衰减。在模拟粒子的每个时间步长中,速度会通过指定的摩擦系数进行缩放。因此,1的值对应于无摩擦环境,而0的值会使所有粒子立即冻结,因为它们失去了速度。建议不要使用范围之外的值 [0, 1],因为它们可能会使布局不稳定。

好的,现在我们已经了解了干燥的定义,让我们来看看如何利用这些力来生成有趣的视觉效果。

设置零力布局

首先,我们简单地设置一个没有重力和电荷的力布局。力布局可以通过使用d3.layout.force函数来创建:

var w = 1280, h = 800,
        force = d3.layout.force()
            .size([w ,h])
            .gravity(0)
            .charge(0)
            .friction(0.7);

在这里,我们将布局的大小设置为我们的 SVG 图形的大小,这是一个常见的做法,尽管不是强制性的。在某些用例中,您可能会发现有一个比 SVG 大或小的布局是有用的。同时,我们在设置friction0.7的同时禁用了重力和电荷。有了这个设置,我们就可以在用户移动鼠标时在 SVG 上创建表示为svg:circle的额外节点:

svg.on("mousemove", function () {
        var point = d3.mouse(this),
            node = {x: point[0], y: point[1]}; // <-A

        svg.append("circle")
                .data([node])
            .attr("class", "node")
            .attr("cx", function (d) {return d.x;})
            .attr("cy", function (d) {return d.y;})
            .attr("r", 1e-6)
        .transition()
            .attr("r", 4.5)
        .transition()
            .delay(7000)
            .attr("r", 1e-6)
            .each("end", function () {
                force.nodes().shift(); // <-B
            })
            .remove();

        force.nodes().push(node); // <-C
        force.start(); // <-D
});

节点对象最初在行 A 上创建,其坐标设置为当前鼠标位置。像所有其他 D3 布局一样,力布局没有感知并且没有视觉元素。因此,我们创建的每个节点都需要在行 C 上添加到布局的节点数组中,并在行 B 上移除这些节点的视觉表示。在行 D 上,我们调用start函数以开始力模拟。在没有重力和电荷的情况下,布局基本上允许我们通过鼠标移动放置一串节点,如下面的截图所示:

设置零力布局

无重力或电荷

设置相互排斥

在下一个模式中,我们将电荷设置为负值,同时保持重力为零,以产生相互排斥的力场:

function changeForce(charge, gravity) {
    force.charge(charge).gravity(gravity);
}
changeForce(-60, 0);

这些行告诉力布局对每个节点应用-60电荷,并根据每个 tick 的模拟结果相应地更新节点的{x, y}坐标。然而,仅此还不够将粒子移动到 SVG 上,因为布局没有关于视觉元素的知识。接下来,我们需要编写一些代码将力布局正在操作的数据连接到我们的图形元素。以下是要执行此操作的代码:

force.on("tick", function () {
        svg.selectAll("circle")
            .attr("cx", function (d) {return d.x;})
            .attr("cy", function (d) {return d.y;});
});

在这里,我们注册了一个tick事件监听器函数,该函数根据力布局的计算更新所有圆元素到其新位置。Tick 监听器在模拟的每个 tick 上触发。在每个 tick 上,我们将cxcy属性设置为d上的xy值。这是因为我们已将节点对象作为 datum 绑定到这些圆元素上,因此它们已经包含了力布局计算出的新坐标。这有效地建立了力布局对所有粒子的控制。

除了tick之外,力布局还支持一些其他事件:

  • start: 当模拟开始时触发

  • tick: 在模拟的每个 tick 上触发

  • end: 当模拟结束时触发

此力设置产生以下视觉效果:

设置相互排斥

相互排斥

设置相互吸引

当我们将电荷更改为正值时,粒子之间产生相互吸引:

function changeForce(charge, gravity) {
    force.charge(charge).gravity(gravity);
}
changeForce(60, 0);

这产生了以下视觉效果:

设置相互吸引

相互吸引

设置重力

当我们打开重力并关闭电荷时,它会产生类似于相互吸引的效果;然而,你可以注意到当鼠标从中心移开时,重力吸引的线性缩放:

function changeForce(charge, gravity) {
    force.charge(charge).gravity(gravity);
}
changeForce(0, 0.02);

仅使用重力时,这个菜谱会产生以下效果:

设置重力

重力

使用带有排斥力的重力

最后,我们可以打开重力和相互排斥。结果是力的平衡,使所有粒子保持某种稳定性,既不会逃离布局,也不会相互碰撞:

function changeForce(charge, gravity) {
    force.charge(charge).gravity(gravity);
}
changeForce(-30, 0.1);

这是这种力平衡的外观:

使用带有排斥力的重力

带有排斥力的重力

参见

生成动量

在我们之前的菜谱中,我们提到了力布局节点对象及其 {x, y} 属性,这些属性决定了节点在布局中的位置。在这个菜谱中,我们将讨论物理运动模拟的另一个有趣方面:动量。D3 力布局内置了对动量模拟的支持,这依赖于节点对象上的 {px, py} 属性。让我们看看在这个菜谱中描述的示例中如何实现这一点。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter11/momentum-and-friction.html.

如何操作...

在这个菜谱中,我们将通过首先禁用重力和电荷,然后给新添加的节点一些初始速度来修改之前的菜谱。结果现在鼠标移动得越快,每个节点的初始速度和动量就越高。以下是实现这一点的代码:

<script type="text/javascript">
    var force = d3.layout.force()
            .gravity(0)
            .charge(0)
            .friction(0.95);

    var svg = d3.select("body").append("svg:svg");

    force.on("tick", function () {
        // omitted, same as previous recipe
       ...
    });

    var previousPoint;

    svg.on("mousemove", function () {
        var point = d3.mouse(this),
            node = {
                x: point[0],
                y: point[1],
                px: previousPoint ? previousPoint[0] : point[0],
                py: previousPoint ? previousPoint[1] : point[1]
            };

        previousPoint = point;

        // omitted, same as previous recipe
       ...
    });
</script> 

这个菜谱生成一个粒子系统,其初始方向速度与用户的鼠标移动成正比,如以下截图所示:

如何操作...

动量

它是如何工作的...

这个菜谱的整体结构与之前的非常相似。它也像用户移动鼠标一样生成粒子。此外,一旦力模拟开始,粒子位置就完全由其tick事件监听器函数中的力布局控制。然而,在这个菜谱中,我们关闭了重力和电荷,以便我们可以更清晰地专注于动量。我们留下了一些摩擦,使得速度衰减使模拟看起来更真实。以下是我们的力布局配置:

var force = d3.layout.force()
            .gravity(0)
            .charge(0)
            .friction(0.95);

在这个菜谱中的主要区别是,我们不仅跟踪当前鼠标位置,还跟踪前一个鼠标位置。此外,每当用户移动鼠标时,我们都会生成一个包含当前位置{x, y}以及前一个位置{px, py}的节点对象:

    var previousPoint;

    svg.on("mousemove", function () {
        var point = d3.mouse(this),
            node = {
                x: point[0],
                y: point[1],
                px: previousPoint ? previousPoint[0] : point[0],
                py: previousPoint ? previousPoint[1] : point[1]
            };

        previousPoint = point;
        ...
    }

由于用户鼠标位置是在固定间隔上采样的,用户移动鼠标的速度越快,这两个位置之间的距离就越远。这个属性加上从这两个位置获得的方向信息,被力布局自动很好地转换成我们在这个菜谱中展示的每个粒子的初始动量。

除了我们之前讨论的{x, y, px, py}属性之外,力布局节点对象还支持一些其他有用的属性,我们将在此列出供您参考:

  • index:节点在节点数组中的零基索引。

  • x:当前节点位置的 x 坐标。

  • y:当前节点位置的 y 坐标。

  • px:前一个节点位置的 x 坐标。

  • py:前一个节点位置的 y 坐标。

  • fixed:一个布尔值,表示节点位置是否被锁定。

  • weight:节点权重;关联的链接数量。链接用于在力布局中连接节点,我们将在下一菜谱中深入探讨。

参见

  • 在第十章的与你的可视化交互中,与你的可视化交互与鼠标事件交互菜谱,了解更多关于如何在 D3 中与鼠标交互的细节

  • D3 Force Layout Nodes API,有关力布局节点属性的更多详细信息 github.com/mbostock/d3/wiki/Force-Layout#wiki-nodes

设置链接约束

到目前为止,我们已经涵盖了力布局的一些重要方面,如重力、电荷、摩擦和动量。在这个菜谱中,我们将讨论另一个关键功能:链接。正如我们在介绍部分提到的,D3 力布局实现了一个可扩展的简单图约束,在这个菜谱中,我们将演示如何结合其他力利用链接约束。

准备工作

打开以下文件的本地副本到您的网页浏览器中:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter11/link-constraint.html.

如何操作...

在这个配方中,每当用户点击鼠标时,我们都会生成一个由节点之间的链接约束的力导向粒子环。以下是它的实现方式:

<script type="text/javascript">
    var force = d3.layout.force()
            .gravity(0.1)
            .charge(-30)
            .friction(0.95)
            .linkDistance(20)
            .linkStrength(1);

    var duration = 60000; // in milliseconds

    var svg = d3.select("body").append("svg:svg");

    force.size([1100, 600])
        .on("tick", function () {
            // omitted, will be discussed in details later
            ...
        });

    function offset() {
        return Math.random() * 100;
    }

    function createNodes(point) {
        var numberOfNodes = Math.round(Math.random() * 10);
        var nodes = [];

        for (var i = 0; i < numberOfNodes; ++i) {
            nodes.push({
                x: point[0] + offset(), 
                y: point[1] + offset()
            });
        }

        return nodes;
    }

    function createLinks(nodes) {
        // omitted, will be discussed in details later
        ...
    }

    svg.on("click", function () {
        var point = d3.mouse(this),
                nodes = createNodes(point),
                links = createLinks(nodes);

        nodes.forEach(function (node) {
            svg.append("circle")
                    .data([node])
                .attr("class", "node")
                .attr("cx", function (d) {return d.x;})
                .attr("cy", function (d) {return d.y;})
                .attr("r", 1e-6)
                .call(force.drag)
                    .transition()
                .attr("r", 7)
                    .transition()
                    .delay(duration)
                .attr("r", 1e-6)
                .each("end", function () {force.nodes().shift();})
                .remove();
        });

        links.forEach(function (link) {
            // omitted, will be discussed in details later
            ...
        });

        nodes.forEach(function (n) {force.nodes().push(n);});
        links.forEach(function (l) {force.links().push(l);});

        force.start();
    });
</script>

此配方在鼠标点击时生成力导向的粒子环,如下面的截图所示:

如何操作...

力导向粒子环

工作原理...

链接约束为力辅助可视化添加了另一个有用的维度。在这个配方中,我们使用以下参数设置我们的力布局:

var force = d3.layout.force()
            .gravity(0.1)
            .charge(-30)
            .friction(0.95)
            .linkDistance(20)
            .linkStrength(1);

除了重力、电荷和摩擦之外,这次我们还有两个额外的参数:链接距离和链接强度。这两个参数都是与链接相关的:

  • linkDistance: 可以为一个常量或一个函数;默认为 20 像素。链接距离在布局开始时进行评估,并且它被实现为弱几何约束。对于布局的每一次迭代,都会计算每对链接节点之间的距离,并将其与目标距离进行比较;然后链接会相互靠近或远离。

  • linkStength: 可以为一个常量或一个函数;默认为 1。链接强度设置链接的强度(刚性),其值在[0, 1]范围内。链接强度也在布局开始时进行评估。

当用户点击鼠标时,会创建一定数量的节点并将其置于力布局的控制之下,这与我们在之前的配方中所做的一样。在这个配方中的主要新增内容是链接的创建及其控制逻辑,如下面的代码片段所示:

    function createLinks(nodes) {
        var links = [];
        for (var i = 0; i < nodes.length; ++i) { // <-A
            if(i == nodes.length - 1) 
                links.push(
                    {source: nodes[i], target: nodes[0]}
                );
            else
                links.push(
                    {source: nodes[i], target: nodes[i + 1]}
                );
        }
        return links;
    }
...
svg.on("click", function () {
        var point = d3.mouse(this),
                nodes = createNodes(point),
                links = createLinks(nodes);
    ...

        links.forEach(function (link) {
            svg.append("line") // <-B
                    .data([link])
                .attr("class", "line")
                .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;
    })
                    .transition()
                    .delay(duration)
                .style("stroke-opacity", 1e-6)
                .each("end", function () {
                   force.links().shift();
    })
                .remove();
        });

        nodes.forEach(function (n) {force.nodes().push(n);});
        links.forEach(function (l) { // <-C
          force.links().push(l);
   });

        force.start();
}

createLinks函数中,创建了n-1个链接对象,将一组节点连接成一个环(在 A 行上的 for 循环)。每个链接对象必须指定两个属性,即sourcetarget,告诉力布局哪些节点对通过此链接对象连接。一旦创建,我们决定在这个配方中使用svg:line元素(B 行)来可视化链接。在下一个配方中,我们将看到这并不总是必须如此。事实上,你可以使用几乎所有东西来可视化(包括隐藏它们,但保留链接以进行布局计算),只要这对你的可视化观众有意义。之后,我们还需要将链接对象添加到力布局的链接数组中(C 行),以便它们可以置于力布局的控制之下。最后,我们需要在tick函数中为每个链接将力布局生成的位置数据转换为 SVG 实现,类似于我们对节点所做的那样:

force.size([1100, 600])
        .on("tick", function () {
            svg.selectAll("circle")
                .attr("cx", function (d) {return d.x;})
                .attr("cy", function (d) {return d.y;});

            svg.selectAll("line")
                .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 力布局再次承担了大部分繁重的工作,因此我们只需要在tick函数中的svg:line元素上简单地设置{x1, y1}{x2, y2}。为了参考,以下截图是链接对象在经过力布局操作后的样子:

工作原理...

链接对象

在本配方中,还有一个值得提及的额外技术,即力启用拖动。本配方生成的所有节点都是“可拖动的”,并且当用户拖动环时,力布局会自动重新计算所有力和约束,如下截图所示:

如何工作...

力布局拖动

D3 力布局内置了拖动功能,因此,通过在 svg:circle 选择器上简单地调用 force.drag,就可以轻松实现这种花哨的效果(代码行 D):

nodes.forEach(function (node) {
            svg.append("circle")
                    .data([node])
                .attr("class", "node")
                ...
                .call(force.drag) // <-D
                    .transition()
                ...
                .each("end", function () {force.nodes().shift();})
                .remove();
        });

参考以下

使用力辅助可视化

到目前为止,我们已经学会了如何使用力布局可视化粒子及其链接,类似于在经典应用中(如力导向图)使用力布局。这种可视化正是力布局最初设计的目的。然而,这绝对不是利用力进行可视化的唯一方式。在本配方中,我们将探讨我称之为力辅助可视化的技术。利用这种技术,您可以通过利用力来为您的可视化添加一些随机性和任意性。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter11/arbitrary-visualization.html.

如何操作...

在本配方中,我们将生成用户鼠标点击时的气泡。这些气泡由填充渐变颜色的 svg:path 元素组成。尽管 svg:path 元素不是严格由力布局控制的,但它们受到力的影响,因此,赋予它们所需的随机性来模拟现实生活中的气泡:

<svg>
    <defs>
        <radialGradient id="gradient" cx="50%" cy="50%" r="100%" fx="50%" fy="50%">
            <stop offset="0%" style="stop-color:blue;stop-opacity:0"/>
            <stop offset="100%" style="stop-color:rgb(255,255,255);stop-opacity:1"/>
        </radialGradient>
    </defs>
</svg>

<script type="text/javascript">
    var force = d3.layout.force()
            .gravity(0.1)
            .charge(-30)
            .friction(0.95)
            .linkDistance(20)
            .linkStrength(0.5);

    var duration = 10000;

    var svg = d3.select("svg");

    var line = d3.svg.line()
            .interpolate("basis-closed")
            .x(function(d){return d.x;})
            .y(function(d){return d.y;});

    force.size([svg.node().clientWidth, svg.node().clientHeight])
        .on("tick", function () {
            // omitted, will be discussed in details later
            ...
        });

    function offset() {
        return Math.random() * 100;
    }

    function createNodes(point) {
        // omitted, same as previous recipe
       ...
    }

    function createLinks(nodes) {
        // omitted, same as previous recipe
       ...
    }

    svg.on("click", function () {
        // omitted, will be discussed in details later
        ...
    });
</script>

本配方在用户鼠标点击时生成力辅助气泡,如下截图所示:

如何操作...

力辅助气泡

如何工作...

这个配方建立在之前配方的基础上,因此其整体方法与上一个配方(我们在用户鼠标点击时创建力控制的粒子环)非常相似。这个配方与上一个配方的主要区别在于,我们决定使用d3.svg.line生成器来创建轮廓我们气泡的svg:path元素,而不是使用svg:circlesvg:line

var line = d3.svg.line() // <-A
            .interpolate("basis-closed")
            .x(function(d){return d.x;})
            .y(function(d){return d.y;});
...
svg.on("click", function () {
        var point = d3.mouse(this),
                nodes = createNodes(point),
                links = createLinks(nodes);

        var circles = svg.append("path")
                .data([nodes])
            .attr("class", "bubble")
            .attr("fill", "url(#gradient)") // <-B
            .attr("d", function(d){return line(d);}) // <-C
                .transition().delay(duration)
            .attr("fill-opacity", 0)
            .attr("stroke-opacity", 0)
            .each("end", function(){d3.select(this).remove();});

        nodes.forEach(function (n) {force.nodes().push(n);});
        links.forEach(function (l) {force.links().push(l);});

        force.start();
});

在线 A 上,我们使用basis-closed插值模式创建了一个线生成器,因为这样可以给我们气泡最平滑的轮廓。每当用户点击鼠标时,就会创建一个svg:path元素,连接所有节点(线 C)。此外,我们还用我们预定义的渐变填充气泡,使其看起来很漂亮(线 B)。最后,我们还需要在tick函数中实现基于力的定位:

force.size([svg.node().clientWidth, svg.node().clientHeight])
        .on("tick", function () {
            svg.selectAll("path")
                .attr("d", line);
        });

tick函数中,我们简单地重新调用线生成器函数来更新每个路径的d属性,从而使用力布局计算来动画化气泡。

相关内容

  • SVG 渐变和图案:www.w3.org/TR/SVG/pservers.html

  • 在第七章的“塑形”一节中,有关 D3 线生成器的使用线生成器配方,获取更多信息

力操纵

到目前为止,我们已经探索了 D3 力布局的许多有趣方面和应用;然而,在所有这些先前的配方中,我们只是直接将力布局的计算(重力、电荷、摩擦和动量)应用于我们的可视化。在这个配方中,我们将更进一步,实现自定义力操纵,从而创建我们自己的力类型。

在这个配方中,我们首先生成五组彩色粒子,然后我们为用户的触摸分配相应的颜色和分类力,从而只拉动匹配颜色的粒子。由于这个配方比较复杂,我在这里给出一个例子:如果我用我的第一个手指触摸可视化,它将生成一个蓝色圆圈并将所有蓝色粒子拉到那个圆圈,而我的第二个触摸将生成一个橙色圆圈,并且只拉动橙色粒子。这种力操纵通常被称为分类多焦点。

准备工作

在您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter11/multi-foci.html.

如何做到这一点...

下面是如何在代码中实现它的方法:

<script type="text/javascript">
    var svg = d3.select("body").append("svg:svg"),
            colors = d3.scale.category10(),
            w = 900,
            h = 600;

    svg.attr("width", w).attr("height", h);

    var force = d3.layout.force()
            .gravity(0.1)
            .charge(-30)
            .size([w, h]);

    var nodes = force.nodes(),
            centers = [];

    for (var i = 0; i < 5; ++i) {
        for (var j = 0; j < 50; ++j) {
            nodes.push({x: w / 2 + offset(), 
              y: h / 2 + offset(), 
              color: colors(i), // <-A
              type: i}); // <-B
        }
    }

    function offset() {
        return Math.random() * 100;
    }

    svg.selectAll("circle")
                .data(nodes).enter()
            .append("circle")
            .attr("class", "node")
            .attr("cx", function (d) {return d.x;})
            .attr("cy", function (d) {return d.y;})
            .attr("fill", function(d){return d.color;})
            .attr("r", 1e-6)
                .transition()
            .attr("r", 4.5);

    force.on("tick", function(e) {
          // omitted, will discuss in detail
    ...
    });

    d3.select("body")
        .on("touchstart", touch)
        .on("touchend", touch);

    function touch() {
        // omitted, will discuss in detail
        ...
    }

    force.start();
</script>

此配方在触摸时生成多分类焦点,如下截图所示:

如何做到这一点...

触摸时的多分类焦点

它是如何工作的...

此配方的第一步是创建彩色粒子以及重力和排斥力之间的标准力平衡。所有节点对象都包含单独的颜色和类型 ID 属性(行 A 和 B),这样它们就可以在以后轻松识别。接下来,我们需要在用户触摸时创建一个svg:circle元素来表示触摸点:

function touch() {
        d3.event.preventDefault();

        centers = d3.touches(svg.node());

        var g = svg.selectAll("g.touch")
                .data(centers, function (d) {
                    return d.identifier;
                });

        g.enter()
            .append("g")
            .attr("class", "touch")
            .attr("transform", function (d) {
                return "translate(" + d[0] + "," + d[1] + ")";
            })
            .append("circle")
                .attr("class", "touch")
                .attr("fill", function(d){
                   return colors(d.identifier);
                })
                    .transition()
                .attr("r", 50);

        g.exit().remove();

        force.resume();
}

一旦确定了接触点,所有自定义的力魔法都在tick函数中实现。现在,让我们来看看tick函数:

force.on("tick", function(e) {
          var k = e.alpha * .2;
          nodes.forEach(function(node) {
            var center = centers[node.type];
            if(center){
                node.x += (center[0] - node.x) * k; // <-C
                node.y += (center[1] - node.y) * k; // <-D
            }
          });

          svg.selectAll("circle")
              .attr("cx", function(d) { return d.x; })
              .attr("cy", function(d) { return d.y; });
});

我们在这里遇到的第一种新概念是 alpha 参数。Alpha 是力布局使用的内部冷却参数。Alpha 从0.1开始,随着布局的 tick 值向0移动。简单来说,alpha 值越高,力越混乱,当 alpha 接近0时,布局变得更加稳定。在这个实现中,我们利用 alpha 值来使我们的自定义力实现与内置的其他力同步冷却,因为粒子的运动是通过k系数(alpha 的导数)在行 C 和 D 上计算的,将它们移动到匹配的触摸点附近。

参见

  • 在第十章的与你的可视化交互部分,与多触点设备交互的配方,了解更多关于 D3 多触点支持的信息

构建力导向图

最后,我们将展示如何实现一个力导向图,这是 D3 力布局的经典应用。然而,我们认为,凭借你从本章学到的所有技术和知识,实现力导向图应该感觉相当直接。

准备工作

在你的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter11/force-directed-graph.html

如何做...

在这个配方中,我们将可视化 flare 数据集作为一个力导向树(树是图的一种特殊类型):

<script type="text/javascript">
    var w = 1280,
        h = 800,
        z = d3.scale.category20c();

    var force = d3.layout.force()
        .size([w, h]);

    var svg = d3.select("body").append("svg")
        .attr("width", w)
        .attr("height", h);

    d3.json("/data/flare.json", function(root) {
      var nodes = flatten(root),
          links = d3.layout.tree().links(nodes); // <-B

      force
          .nodes(nodes)
          .links(links)
          .start();

      var link = svg.selectAll("line")
          .data(links)
        .enter().insert("line")
          .style("stroke", "#999")
          .style("stroke-width", "1px");

      var node = svg.selectAll("circle.node")
          .data(nodes)
        .enter().append("circle")
          .attr("r", 4.5)
          .style("fill", function(d) { 
             return z(d.parent && d.parent.name); 
})
          .style("stroke", "#000")
          .call(force.drag);

      force.on("tick", function(e) {
        link.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; });

        node.attr("cx", function(d) { return d.x; })
            .attr("cy", function(d) { return d.y; });
      });
    });

    function flatten(root) { // <-A
      var nodes = [];
      function traverse(node, depth) {
        if (node.children) {
          node.children.forEach(function(child) {
            child.parent = node;
            traverse(child, depth + 1);
          });
        }
        node.depth = depth;
        nodes.push(node);
      }
      traverse(root, 1);
      return nodes;
    }
</script>

这个配方将层次化的 flare 数据集可视化为一个力导向树:

如何做...

力导向图(树)

它是如何工作的...

如我们所见,这个菜谱相当简短,四分之一的代码实际上用于数据处理。这是因为力导向图最初就是为了力布局而设计的。因此,除了简单地应用正确的数据结构来布局之外,实际上并没有太多的事情要做。首先,我们在flatten函数(行 A)中使层次化数据集扁平化,因为这是力布局所期望的。其次,我们利用d3.layout.tree.links函数在树节点之间生成适当的链接。d3.layout.tree.links函数返回一个表示从父节点到子节点的链接对象的数组,换句话说,构建树结构。一旦数据格式正确,剩下的这个菜谱就应用标准的力布局用法,几乎没有任何定制。

参见

第十二章。了解你的地图

在本章中,我们将涵盖:

  • 投影美国地图

  • 投影世界地图

  • 构建渐变色地图

简介

能够将数据点投影并关联到地理区域的能力在许多类型的可视化中至关重要。地理可视化是一个复杂的话题,许多标准正在今天的技术中竞争和成熟。D3 提供了几种不同的方式来可视化地理和制图数据。在本章中,我们将介绍基本的 D3 制图可视化技术以及如何在 D3 中实现一个功能齐全的渐变色地图(一种特殊用途的彩色地图)。

投影美国地图

在这个菜谱中,我们将从使用 D3 地图 API 投影美国地图开始,同时熟悉描述地理数据的几种不同的 JSON 数据格式。让我们首先看看地理数据通常是如何在 JavaScript 中表示和消费的。

GeoJSON

我们将要接触的第一个标准 JavaScript 地理数据格式被称为 GeoJSON。GeoJSON 格式与其他 GIS 标准的不同之处在于,它是由一个开发者的互联网工作组编写和维护的。

GeoJSON 是一种用于编码各种地理数据结构的格式。GeoJSON 对象可以表示几何形状、特征或特征集合。GeoJSON 支持以下几何类型:点(Point)、线字符串(LineString)、多边形(Polygon)、多点(MultiPoint)、多线字符串(MultiLineString)、多边形(MultiPolygon)和几何集合(GeometryCollection)。GeoJSON 中的特征包含一个几何对象和额外的属性,而特征集合表示特征列表。

来源:www.geojson.org/

GeoJSON 格式是编码 GIS 信息的非常流行的标准,被众多开源和商业软件支持。GeoJSON 格式使用经纬度点作为其坐标,因此,它要求包括 D3 在内的任何软件找到适当的投影、缩放和转换方法,以便可视化其数据。以下 GeoJSON 数据描述了以特征坐标表示的阿拉巴马州的状态:

{
  "type":"FeatureCollection",
  "features":[{
    "type":"Feature",
    "id":"01",
    "properties":{"name":"AL"},
    "geometry":{
      "type":"Polygon",
      "coordinates":[[
        [-87.359296,35.00118],
        [-85.606675,34.984749],
        [-85.431413,34.124869],
        [-85.184951,32.859696],
        ...
        [-88.202745,34.995703],
        [-87.359296,35.00118]
      ]]
  }]
}

GeoJSON 目前是 JavaScript 项目中事实上的 GIS 信息标准,并且得到了 D3 的良好支持;然而,在我们直接跳入使用这种数据格式进行 D3 地理可视化之前,我们还想向您介绍另一种与 GeoJSON 密切相关的正在兴起的科技。

TopoJSON

TopoJSON 是 GeoJSON 的一个扩展,用于编码拓扑。在 TopoJSON 文件中,几何形状不是离散表示的,而是由称为弧的共享线段拼接而成的。TopoJSON 消除了冗余,提供了比 GeoJSON 更紧凑的几何形状表示;典型的 TopoJSON 文件比其 GeoJSON 等效文件小 80%。此外,TopoJSON 促进了使用拓扑的应用,如拓扑保持形状简化、自动地图着色和地图变形。

TopoJSON Wiki https://github.com/mbostock/topojson

TopoJSON 是由 D3 的作者Mike Bostock创建的,旨在克服 GeoJSON 的一些缺点,同时在描述地理信息时提供类似的功能集。在大多数涉及地图可视化的情况下,TopoJSON 可以作为 GeoJSON 的替代品,具有更小的体积和更好的性能。因此,在本章中,我们将使用 TopoJSON 而不是 GeoJSON。尽管如此,本章中讨论的所有技术也可以与 GeoJSON 完美配合。我们不会在这里列出 TopoJSON 的示例,因为其基于弧的格式不太适合人类阅读。然而,您可以使用 GDAL 提供的命令行工具ogr2ogr轻松地将您的shapefiles(流行的开源地理矢量格式文件)转换为 TopoJSON(www.gdal.org/ogr2ogr.html)。

现在我们有了这些背景信息,让我们看看如何在 D3 中制作地图。

准备工作

在您的本地 HTTP 服务器上托管的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter12/usa.html

如何操作...

在这个配方中,我们将加载美国 TopoJSON 数据并使用 D3 Geo API 进行渲染。以下是代码示例:

<script type="text/javascript">
    var width = 960, height = 500;

    // use default USA Albers projection
    var path = d3.geo.path();

    var svg = d3.select("body").append("svg")
            .attr("width", width)
            .attr("height", height);

    var g = svg.append('g')
            .call(d3.behavior.zoom()
                  .scaleExtent([1, 10])
                  .on("zoom", zoom));

    d3.json("/data/us.json", function (error, topology) { // <-A
        g.selectAll("path") 
                .data(topojson.feature(topology, 
                   topology.objects.states).features)
                .enter().append("path")
                .attr("d", path);
    });

    function zoom() {
        g.attr("transform", "translate("
                + d3.event.translate
                + ")scale(" + d3.event.scale + ")");
    }
</script>

此配方使用 Albers USA 模式投影美国地图:

如何操作...

使用 Albers USA 模式投影的美国地图

如何工作...

如您所见,使用 TopoJSON 和 D3 投影美国地图所需的代码相当简短,尤其是关于地图投影的部分。这是因为 D3 地理 API 和 TopoJSON 库都是专门构建的,以便尽可能简化开发者的工作。要制作地图,首先您需要加载 TopoJSON 数据文件(行 A)。以下截图显示了加载后的拓扑数据的外观:

如何工作...

TopoJSON 拓扑数据

一旦加载了拓扑数据,我们只需使用 TopoJSON 库的topojson.feature函数将拓扑弧转换为类似于 GeoJSON 格式提供的坐标,如下面的截图所示:

如何工作...

使用 topojson.feature 函数转换的特征集合

然后d3.geo.path将自动识别并使用坐标来生成以下代码片段中突出显示的svg:path

var path = d3.geo.path();
...
g.selectAll("path") 
                .data(topojson.feature(topology, 
                   topology.objects.states).features)
                .enter().append("path")
                .attr("d", path);

就这样!这就是使用 TopoJSON 在 D3 中投影地图所需的所有操作。此外,我们还向父svg:g元素附加了一个缩放处理程序:

var g = svg.append('g')
            .call(d3.behavior.zoom()
                  .scaleExtent([1, 10])
                  .on("zoom", zoom));

这允许用户在我们的地图上执行简单的几何缩放。

相关内容

投影世界地图

如果我们的可视化项目不仅仅是关于美国,而是涉及整个世界呢?不用担心,D3 提供了各种内置的投影模式,这些模式在本菜谱中我们将探讨。

准备工作

在您的本地 HTTP 服务器上托管您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter12/world.html

如何操作...

在本菜谱中,我们将使用不同的 D3 内置投影模式来投影世界地图。以下是代码示例:

<script type="text/javascript">
    var width = 300, height = 300,
        translate = [width / 2, height / 2];

    var projections = [ // <-A
        {name: 'azimuthalEqualArea', fn: 
          d3.geo.azimuthalEqualArea()
                .scale(50)
                .translate(translate)},
        {name: 'conicEquidistant', fn: d3.geo.conicEquidistant()
             .scale(35)
                .translate(translate)},
        {name: 'equirectangular', fn: d3.geo.equirectangular()
             .scale(50)
                .translate(translate)},
        {name: 'mercator', fn: d3.geo.mercator()
             .scale(50)
                .translate(translate)},
        {name: 'orthographic', fn: d3.geo.orthographic()
                   .scale(90)
                      	.translate(translate)},
        {name: 'stereographic', fn: d3.geo.stereographic()
                                .scale(35)
                                .translate(translate)}
    ];

    d3.json("/data/world-50m.json", function (error, world) {//<-B    
        projections.forEach(function (projection) {
            var path = d3.geo.path() // <-C
                    .projection(projection.fn);

            var div = d3.select("body")
                    .append("div")
                    .attr("class", "map");

            var svg = div
                    .append("svg")
                    .attr("width", width)
                    .attr("height", height);

            svg.append("path") // <-D
                    .datum(topojson.feature(world, 
                       world.objects.land))
                    .attr("class", "land")
                    .attr("d", path);

            svg.append("path") // <-E
                    .datum(topojson.mesh(world, 
                       world.objects.countries))
                    .attr("class", "boundary")
                    .attr("d", path);

            div.append("h3").text(projection.name);
        });
    });
</script>

本菜谱生成了具有不同投影模式的世界地图,如下面的截图所示:

如何操作...

世界地图投影

它是如何工作的...

在本菜谱中,我们首先在行 A 上定义了一个包含六个不同 D3 投影模式的数组。在行 B 上加载了世界拓扑数据。与之前的菜谱类似,我们在行 C 上定义了一个d3.geo.path生成器;然而,在本菜谱中,我们为地理路径生成器自定义了投影模式,通过调用其projection函数。本菜谱的其余部分几乎与之前所做的相同。topojson.feature函数被用来将拓扑数据转换为地理坐标,以便d3.geo.path可以生成用于地图渲染所需的svg:path(行 D 和 E)。

相关内容

构建着色图

着色图是一种专题地图,换句话说,它是一种专门设计的地图,而不是通用目的的地图,它通过使用不同的颜色阴影或图案在地图上展示统计变量的测量值;或者有时简单地被称为地理热图。在前两个菜谱中,我们已经看到 D3 中的地理投影由一组svg:path元素组成,因此,它们可以被像其他任何svg元素一样操作,包括着色。我们将在本菜谱中探索这个特性,并实现一个着色图。

准备工作

在您的本地 HTTP 服务器上托管您的网络浏览器中打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook/blob/master/src/chapter12/choropleth.html

如何操作...

在渐变地图中,不同的地理区域根据其相应的变量着色,在本例中基于 2008 年美国各县的失业率。现在,让我们看看如何在代码中实现它:

<script type="text/javascript">
    var width = 960, height = 500;

    var color = d3.scale.threshold() // <-A
            .domain([.02, .04, .06, .08, .10])
            .range(["#f2f0f7", "#dadaeb", "#bcbddc", 
             "#9e9ac8", "#756bb1", "#54278f"]);

    var path = d3.geo.path();

    var svg = d3.select("body").append("svg")
            .attr("width", width)
            .attr("height", height);

var g = svg.append("g")
...
    d3.json("/data/us.json", function (error, us) {
        d3.tsv("/data/unemployment.tsv", function (error, 
                                            unemployment) {
            var rateById = {};
            unemployment.forEach(function (d) { // <-B
                rateById[d.id] = +d.rate;
            });

            g.append("g")
                    .attr("class", "counties")
                    .selectAll("path")
                    .data(topojson.feature(us, 
                            us.objects.counties).features)
                    .enter().append("path")
                    .attr("d", path)
                    .style("fill", function (d) {
                        return color(rateById[d.id]); // <-C
                    });

            g.append("path")
                    .datum(topojson.mesh(us, us.objects.states, function(a, b) { return a !== b; }))
                    .attr("class", "states")
                    .attr("d", path);
        });
});
...
</script>

本食谱生成以下渐变地图:

如何操作...

2008 年失业率渐变地图

工作原理...

在本食谱中,我们加载了两个不同的数据集:一个用于美国拓扑结构,另一个包含 2008 年各县的失业率。这种技术通常被认为是分层,并不一定仅限于两层。失业数据通过其 ID(B 行和 C 行)与县连接。区域着色是通过使用阈值刻度(A 行)实现的。最后一点值得提的是,用于渲染州边界的topojson.mesh函数。topojson.mesh在高效渲染复杂对象的线条时非常有用,因为它只渲染多个特征共享的边一次。

参考信息

第十三章。测试驱动你的可视化

在本章中,我们将涵盖:

  • 获取 Jasmine 并设置测试环境

  • 测试驱动你的可视化 - 图表创建

  • 测试驱动你的可视化 - SVG 渲染

  • 测试驱动你的可视化 - 像素级条形图渲染

简介

无论我们作为专业程序员编程,测试我们编写的程序以确保其按设计运行并产生预期结果总是很重要的。D3 数据可视化主要由 JavaScript 程序组成,因此就像我们编写的任何其他程序一样,数据可视化也需要进行测试,以确保它准确地表示底层数据。显然,我们可以通过视觉检查和手动测试来进行验证,这始终是构建数据可视化过程中的一个关键部分,因为视觉观察不仅给我们验证正确性的机会,还验证了美学、可用性以及许多其他有用的方面。然而,手动视觉检查可能相当主观,因此,在本章中,我们将专注于自动化单元测试。单元测试覆盖良好的可视化可以免除创作者手动验证正确性的劳动,同时使他/她能够更多地关注美学、可用性以及其他难以用机器自动化的重要方面。

单元测试简介

单元测试是一种方法,其中程序的最小单元通过另一个称为测试用例的程序进行测试和验证。单元测试背后的逻辑是,在单元级别,程序通常是更简单且更容易测试的。如果我们能够验证程序中的每个单元是否正确,那么将这些正确的单元组合在一起将给我们更高的信心,即集成程序也是正确的。此外,由于单元测试通常成本低且执行速度快,一组单元测试用例可以快速频繁地执行,以提供反馈,判断我们的程序是否按预期运行。

软件测试是一个复杂的话题,到目前为止我们只是触及了表面;然而,由于本章范围有限,我们不得不现在停止介绍并深入到单元测试的开发中。

备注

关于测试的更多信息,请查看以下链接:

测试驱动开发:en.wikipedia.org/wiki/Test-driven_development

代码覆盖率:en.wikipedia.org/wiki/Code_coverage

获取 Jasmine 并设置测试环境

在我们开始编写单元测试用例之前,我们需要设置一个环境,以便我们的测试用例可以执行以验证我们的实现。在这个菜谱中,我们将展示如何为可视化项目设置此环境和必要的库。

准备工作

Jasmine (pivotal.github.io/jasmine/) 是一个用于测试 JavaScript 代码的行为驱动开发BDD)框架。

注意

BDD 是一种软件开发技术,它将测试驱动开发TDD)与领域驱动设计相结合。

我们选择 Jasmine 作为我们的测试框架,因为它在 JavaScript 社区中的流行以及它良好的 BDD 语法。您可以从以下位置下载 Jasmine 库:

github.com/pivotal/jasmine/downloads

下载后,您需要将其解压缩到lib文件夹中。除了lib文件夹外,我们还需要创建srcspec文件夹来存储源文件以及测试用例(在 BDD 术语中,测试用例被称为规范)。以下截图显示了文件夹结构:

准备中

测试目录结构

如何操作...

现在,我们已经在我们的库中有了 Jasmine,接下来要做的事情是设置一个 HTML 页面,该页面将包括 Jasmine 库以及我们的源代码和测试用例,以便它们可以被执行以验证我们的程序。在我们的设置中,这个文件被命名为SpecRunner.html,它包括以下代码:

<head>
    <meta charset="utf-8">
    <title>Jasmine Spec Runner</title>

    <link rel="stylesheet" type="text/css" href="lib/jasmine-1.3.1/jasmine.css">
    <script type="text/javascript" src="img/jasmine.js"></script>
    <script type="text/javascript" src="img/jasmine-html.js"></script>
    <script type="text/javascript" src="img/d3.js"></script>

    <!-- include source files here... -->
    <script type="text/javascript" src="img/bar_chart.js"></script>
    <!-- include spec files here... -->
    <script type="text/javascript" src="img/spec_helper.js"></script>
    <script type="text/javascript" src="img/bar_chart_spec.js"></script>

    <script type="text/javascript">
        (function () {
            var jasmineEnv = jasmine.getEnv();
            jasmineEnv.updateInterval = 1000;

            var htmlReporter = new jasmine.HtmlReporter();

            jasmineEnv.addReporter(htmlReporter);

            jasmineEnv.specFilter = function (spec) {
                return htmlReporter.specFilter(spec);
            };

            var currentWindowOnload = window.onload;

            window.onload = function () {
                if (currentWindowOnload) {
                    currentWindowOnload();
                }
                execJasmine();
            };

            function execJasmine() {
                jasmineEnv.execute();
            }

        })();
    </script>

</head>

如何操作...

此代码遵循标准的 Jasmine 规范运行器结构,并将执行报告直接生成到我们的 HTML 页面中。现在,您已经为您的可视化开发设置了一个完整的测试环境。如果您用浏览器打开SpecRunner.html文件,您现在会看到一个空白页面;然而,如果您查看我们的代码示例,您将看到以下报告:

工作原理

Jasmine 报告

参见

测试驱动你的可视化 - 图表创建

在测试环境准备就绪后,我们可以继续开发一个简单的条形图,这与我们在第八章的“创建条形图”食谱中做的是非常相似的,尽管这次是以测试驱动的形式。如果你打开tdd-bar-chart.html页面,你可以看到条形图的外观:

测试驱动你的可视化 - 图表创建

测试驱动条形图

到目前为止,我们所有人都非常清楚如何使用 D3 实现条形图;然而,构建条形图并不是本食谱的重点。相反,我们想展示如何每一步都构建测试用例,并自动验证我们的条形图实现是否正在执行其应有的功能。本食谱的源代码是使用测试驱动开发方法构建的;然而,由于本书的篇幅限制,我们不会展示 TDD 过程中的每一步。相反,我们将多个步骤组合成三个较大的部分,每个部分在本章和本食谱中都有不同的重点,而这个食谱是我们迈出的第一步。

如何操作...

我们需要采取的第一步是确保我们的条形图实现存在并且可以接收数据。我们的开发起点可以是任意的,我们决定从这个最简单的函数开始,为我们的对象设置框架。以下是测试用例的样子:

describe('BarChart', function () {
    var div,
        chart,
        data = [
            {x: 0, y: 0},
            {x: 1, y: 3},
            {x: 2, y: 6}
        ];

    beforeEach(function () {
        div = d3.select('body').append('div');
        chart = BarChart(div);
    });

    afterEach(function () {
        div.remove();
    });

    describe('.data', function () {
        it('should allow setting and retrieve chart data', function () {
            expect(chart.data(data).data()).toBe(data);
        });
});
});

它是如何工作的...

在这个第一个测试用例中,我们使用了几个 Jasmine 构造:

  • describe:这个函数定义了一系列测试用例;在describe内部可以嵌套子测试套件,并定义测试用例。

  • it:这个函数定义了一个测试用例

  • beforeEach:这个函数定义了一个执行前钩子,它将在每个测试用例执行前执行给定的函数。

  • afterEach:这个函数定义了一个执行后钩子,它将在每个测试用例执行后执行给定的函数。

  • expect:这个函数在测试用例中定义了一个期望,然后可以通过匹配器(例如,toBetoBeEmpty)进行链式调用,以在测试用例中执行断言。

在我们的例子中,我们使用beforeEach钩子为每个测试用例设置一个div容器,然后在afterEach钩子中删除div以改善不同测试用例之间的隔离。测试用例本身几乎是微不足道的;它检查条形图是否可以接收数据并正确返回数据属性。到目前为止,如果我们运行我们的 SpecRunner,它将显示一条红色消息,抱怨没有BarChart对象,所以让我们创建我们的对象和函数:

function BarChart(p) {
var that = {};
var _parent = p, data;
that.data = function (d) {
        if (!arguments.length) return _data;
        _data = d;
        return that;
};

return that;
}

现在,如果你再次运行SpecRunner.html,它将显示一个快乐的绿色消息,表明我们的唯一测试用例已经通过。

测试驱动你的可视化——SVG 渲染

现在我们已经创建了条形图对象的初步框架,并且觉得我们已经准备好尝试渲染一些内容了,所以在这个第二次迭代中,我们将尝试生成svg:svg元素。

如何做到这一点...

渲染svg:svg元素不仅应该简单地将svg:svg元素添加到 HTML 主体中,还应该将我们的图表对象的宽度和高度设置转换为正确的 SVG 属性。以下是我们如何在测试用例中表达我们的期望:

describe('.render', function () {
        describe('svg', function () {
            it('should generate svg', function () {
                chart.render();
                expect(svg()).not.toBeEmpty();
            });

            it('should set default svg height and width', 
              function () {
                chart.render();
                expect(svg().attr('width')).toBe('500');
                expect(svg().attr('height')).toBe('350');
            });

            it('should allow changing svg height and width', 
              function () {
                chart.width(200).height(150).render();
                expect(svg().attr('width')).toBe('200');
                expect(svg().attr('height')).toBe('150');
            });
        });
});

function svg() {
    return div.select('svg');
}

它是如何工作的...

到目前为止,所有这些测试都将失败,因为我们甚至没有渲染函数;然而,它清楚地说明了我们期望渲染函数生成svg:svg元素并正确设置widthheight属性。第二个测试用例还确保,如果用户没有提供heightwidth属性,我们将提供一组默认值。以下是我们将如何实现渲染方法以满足这些期望:

...
var _parent = p, _width = 500, _height = 350
        _data;

    that.render = function () {
        var svg = _parent
            .append("svg")
            .attr("height", _height)
            .attr("width", _width);
    };

    that.width = function (w) {
        if (!arguments.length) return _width;
        _width = w;
        return that;
    };

    that.height = function (h) {
        if (!arguments.length) return _height;
        _height = h;
        return that;
};
...

到目前为止,我们的SpecRunner.html再次全部显示为绿色和快乐。然而,它实际上并没有做很多事情,因为它只是在页面上生成一个空的svg 元素,甚至没有使用任何数据。

测试驱动你的可视化——像素级完美的条形渲染

在这次迭代中,我们最终将使用我们拥有的数据生成条形图。通过我们的测试用例,我们将确保所有条形图不仅被渲染,而且渲染得像素级精确。

如何实现...

让我们看看我们是如何测试它的:

describe('chart body', function () {
        it('should create body g', function () {
            chart.render();
            expect(chartBody()).not.toBeEmpty();
        });

        it('should translate to (left, top)', function () {
            chart.render();
             expect(chartBody().attr('transform')).toBe('translate(30,10)')
        });
    });

    describe('bars', function () {
        beforeEach(function () {
            chart.data(data).width(100).height(100)
                .x(d3.scale.linear().domain([0, 3]))
                .y(d3.scale.linear().domain([0, 6]))
                .render();
        });

        it('should create 3 svg:rect elements', function () {
            expect(bars().size()).toBe(3);
        });

        it('should calculate bar width automatically', 
          function () {
            bars().each(function () {expect(d3.select(this).attr('width')).toBe('18');
            });
        });

       it('should map bar x using x-scale', function () {expect(d3.select(bars()[0][0]).attr('x')).toBe('0');expect(d3.select(bars()[0][1]).attr('x')).toBe('20');expect(d3.select(bars()[0][2]).attr('x')).toBe('40');
       });

       it('should map bar y using y-scale', function () {expect(d3.select(bars()[0][0]).attr('y')).toBe('60');expect(d3.select(bars()[0][1]).attr('y')).toBe('30');expect(d3.select(bars()[0][2]).attr('y')).toBe('0');
       });

       it('should calculate bar height based on y', 
          function () {expect(d3.select(bars()[0][0]).
            attr('height')).toBe('10');expect(d3.select(bars()[0][1]).attr('height')).toBe('40');expect(d3.select(bars()[0][2]).attr('height')).toBe('70');
        });
    });

 	 function chartBody() {
        return svg().select('g.body');
    }

    function bars() {
        return chartBody().selectAll('rect.bar');
}

它是如何工作的...

在先前的测试套件中,我们描述了我们期望图表主体 svg:g 元素能够正确变换,并且设置了正确数量的条形图,并带有适当的属性(widthxyheight)。实际上,实现过程将比我们的测试用例要短,这在经过良好测试的实现中是很常见的:

...
var _parent = p, _width = 500, _height = 350,
        _margins = {top: 10, left: 30, right: 10, bottom: 30},
        _data,
        _x = d3.scale.linear(),
        _y = d3.scale.linear();

that.render = function () {
        var svg = _parent
            .append("svg")
            .attr("height", _height)
            .attr("width", _width);

        var body = svg.append("g")
            .attr("class", 'body')
            .attr("transform", "translate(" + _margins.left + "," + _margins.top + ")")

        if (_data) {
            _x.range([0, quadrantWidth()]);
            _y.range([quadrantHeight(), 0]);

            body.selectAll('rect.bar')
                .data(_data).enter()
                .append('rect')
                .attr("class", 'bar')
                .attr("width", function () {
                    return quadrantWidth() / _data.length - BAR_PADDING;
                })
                .attr("x", function (d) {return _x(d.x); })
                .attr("y", function (d) {return _y(d.y); })
                .attr("height", function (d) {
                    return _height - _margins.bottom - _y(d.y);
                });
        }
};
...

我想你已经明白了这个道理,现在你可以一遍又一遍地重复这个循环来推动你的实现。D3 可视化建立在 HTML 和 SVG 之上,这两种都是简单的标记语言,可以轻松验证。精心设计的测试套件可以确保你的可视化不仅像素级精确,甚至可以达到亚像素级精确。

参考以下内容

附录 A. 几分钟内构建交互式分析

在本附录中,我们将涵盖以下内容:

  • crossfilter.js 库

  • 维度图表 – dc.js

简介

恭喜!你已经完成了一本关于数据可视化的 D3 整本书。我们一起探讨了各种主题和技术。此时,你可能会同意,即使有了像 D3 这样强大的库的帮助,构建交互式、准确且美观的数据可视化也不是一件简单的事情。即使不考虑通常在后台所需的工作量,完成一个专业的数据可视化项目通常也需要几天甚至几周的时间。那么,如果你需要快速构建交互式分析,或者在一个完整可视化项目开始之前进行一个概念验证,而你需要在几分钟内完成这项工作,那会怎样呢?在本附录中,我们将向你介绍两个 JavaScript 库,它们允许你在几分钟内完成这些工作:在浏览器中快速构建多维数据交互式分析。

crossfilter.js 库

Crossfilter 也是一个由 D3 的作者 Mike Bostock 创建的库,最初用于为 Square Register 提供分析功能。

Crossfilter 是一个用于在浏览器中探索大型多变量数据的 JavaScript 库。Crossfilter 支持与协调视图进行极快的(<30ms)交互,即使是在包含百万或更多记录的数据集中也是如此。

-Crossfilter Wiki (2013 年 8 月)

换句话说,Crossfilter 是一个库,你可以用它在大型的通常平坦的多变量数据集上生成数据维度。那么,什么是数据维度呢?数据维度可以被视为一种数据分组或分类,而每个维度的数据元素是一个分类变量。由于这仍然是一个相当抽象的概念,让我们看一下以下 JSON 数据集,看看它是如何通过 Crossfilter 转换为维度数据集的。假设我们有一个以下扁平的 JSON 数据集,描述了酒吧中的支付交易:

[
  {"date": "2011-11-14T01:17:54Z", "quantity": 2, "total": 190, "tip": 100, "type": "tab"},
  {"date": "2011-11-14T02:20:19Z", "quantity": 2, "total": 190, "tip": 100, "type": "tab"},
  {"date": "2011-11-14T02:28:54Z", "quantity": 1, "total": 300, "tip": 200, "type": "visa"},
..
]

注意

从 Crossfilter Wiki 借用的样本数据集:github.com/square/crossfilter/wiki/API-Reference

在这个样本数据集中,我们看到了多少维度?答案是:它有与你可以对数据进行分类的不同方式一样多的维度。例如,由于这些数据是关于客户支付的,这是一种时间序列的观察,显然“日期”是一个维度。其次,支付类型是自然地对数据进行分类的方式;因此,“类型”也是一个维度。下一个维度有点棘手,因为从技术上讲,我们可以将数据集中的任何字段建模为维度或其导数;然而,我们不想将任何不帮助我们更有效地切片数据或提供更多洞察数据试图表达的内容的东西作为维度。总计和小费字段具有非常高的基数,这通常是一个维度较差的指标(尽管小费/总计,即小费百分比可能是一个有趣的维度);然而,“数量”字段可能具有相对较小的基数,假设人们不会在这个酒吧购买成千上万杯饮料,因此,我们选择使用数量作为我们的第三个维度。现在,这就是维度逻辑模型看起来像什么:

The crossfilter.js library

维度数据集

这些维度使我们能够从不同的角度观察数据,如果结合使用,将允许我们提出一些相当有趣的问题,例如:

  • 使用账单支付的客户更有可能购买大量商品吗?

  • 客户在周五晚上更有可能购买大量商品吗?

  • 与使用现金相比,客户使用账单支付时更有可能给小费吗?

现在,你可以看到为什么维度数据集是一个如此强大的想法。本质上,每个维度都为你提供了一个不同的视角来观察你的数据,当它们结合在一起时,它们可以迅速将原始数据转化为知识。一个好的分析师可以快速使用这种工具来制定假设,从而从数据中获得知识。

如何做到这一点...

现在,我们理解了为什么我们想要使用我们的数据集建立维度;让我们看看如何使用 Crossfilter 来实现这一点:

var timeFormat = d3.time.format.iso;
var data = crossfilter(json); // <-A

var hours = data.dimension(function(d){
  return d3.time.hour(timeFormat.parse(d.date)); // <-B
});
var totalByHour = hours.group().reduceSum(function(d){
  return d.total;
});

var types = data.dimension(function(d){return d.type;});
var transactionByType = types.group().reduceCount();

var quantities = data.dimension(function(d){return d.quantity;});
var salesByQuantity = quantities.group().reduceCount();

它是如何工作的...

如前所述,在 Crossfilter 中创建维度和组相当直接。在我们能够创建任何内容之前的第一步是,通过调用crossfilter函数将使用 D3 加载的 JSON 数据集通过 Crossfilter 进行传递(行 A)。一旦完成,你可以通过调用dimension函数并传入一个访问器函数来创建你的维度,该函数将检索用于定义维度的数据元素。对于type,我们只需传入function(d){return d.type;}。你还可以在维度函数中执行数据格式化或其他任务(例如,行 B 上的日期格式化)。在创建维度之后,我们可以使用维度进行分类或分组,因此totalByHour是对每个小时的销售额进行求和的分组,而salesByQuantity是对按数量计数的交易进行分组的分组。为了更好地理解group的工作方式,我们将查看组对象的外观。如果你在transactionsByType组上调用all函数,你将得到以下对象:

如何工作...

Crossfilter 组对象

我们可以清楚地看到,transactionByType组本质上是对数据元素按其类型进行分组,并在每个组内计数数据元素的总数,因为我们创建组时调用了reduceCount函数。

以下是我们在这个示例中使用的函数的描述:

  • crossfilter:如果指定,创建一个新的带有给定记录的 crossfilter。记录可以是任何对象数组或原始数据类型。

  • dimension:使用给定的值访问器函数创建一个新的维度。该函数必须返回自然排序的值,即,与 JavaScript 的<、<=、>=和>运算符正确行为的值。这通常意味着原始数据类型:布尔值、数字或字符串。

  • dimension.group:基于给定的groupValue函数创建给定维度的新的分组,该函数接受维度值作为输入并返回相应的舍入值。

  • group.all:按键的自然顺序返回所有组。

  • group.reduceCount:一个用于计数记录的快捷函数;返回此组。

  • group.reduceSum:一个用于使用指定的值访问器函数求和记录的快捷函数。

在这个阶段,我们已经拥有了想要分析的所有内容。现在,让我们看看如何能在几分钟内而不是几小时或几天内完成这项工作。

还有更多...

我们只接触了 Crossfilter 函数的一小部分。当涉及到如何创建维度和组时,Crossfilter 提供了更多的功能;更多信息请查看其 API 参考:github.com/square/crossfilter/wiki/API-Reference

参见

维度图表 – dc.js

可视化 Crossfilter 维度和组正是 dc.js 被创建的原因。这个方便的 JavaScript 库是由你的谦逊作者创建的,旨在让你轻松快速地可视化 Crossfilter 维度数据集。

准备工作

打开以下文件的本地副本作为参考:

github.com/NickQiZhu/d3-cookbook/blob/master/src/appendix-a/dc.html

如何做...

在这个例子中,我们将创建三个图表:

  • 用于可视化时间序列上交易总量的折线图

  • 用于可视化按支付类型交易数量的饼图

  • 展示按购买数量销售数量的条形图

以下是代码的样子:

<div id="area-chart"></div>
<div id="donut-chart"></div>
<div id="bar-chart"></div>
…
dc.lineChart("#area-chart")
                .width(500)
                .height(250)
                .dimension(hours)
                .group(totalByHour)
                .x(d3.time.scale().domain([
                 timeFormat.parse("2011-11-14T01:17:54Z"), 
                  timeFormat.parse("2011-11-14T18:09:52Z")
]))
                .elasticY(true)
                .xUnits(d3.time.hours)
                .renderArea(true)
                .xAxis().ticks(5);

        dc.pieChart("#donut-chart")
                .width(250)
                .height(250)
                .radius(125)
                .innerRadius(50)
                .dimension(types)
                .group(transactionByType);

        dc.barChart("#bar-chart")
                .width(500)
                .height(250)
                .dimension(quantities)
                .group(salesByQuantity)
                .x(d3.scale.linear().domain([0, 7]))
                .y(d3.scale.linear().domain([0, 12]))
                .centerBar(true);

        dc.renderAll();

这会生成一组协调的交互式图表:

如何做...

交互式 dc.js 图表

当你点击或拖动鼠标穿过这些图表时,你将看到所有图表上相应的 Crossfilter 维度被相应地过滤:

如何做...

过滤后的 dc.js 图表

它是如何工作的...

如我们通过这个例子所看到的,dc.js 是设计在 Crossfilter 上生成标准图表可视化工具的。每个 dc.js 图表都设计为交互式的,因此用户可以通过与图表交互来简单地应用维度过滤器。dc.js 完全基于 D3 构建,因此,它的 API 非常类似于 D3,我相信,通过这本书你获得的知识,你会在使用 dc.js 时感到非常熟悉。图表通常按照以下步骤创建。

  1. 第一步是通过调用一个图表创建函数并传入其锚点元素的 D3 选择来创建一个图表对象,在我们的例子中是用于托管图表的 div 元素:

    <div id="area-chart"></div>
    ...
    dc.lineChart("#area-chart")
    
  2. 然后我们为每个图表设置 widthheightdimensiongroup

    chart.width(500)
         .height(250)
         .dimension(hours)
         .group(totalByHour)
    

    对于在笛卡尔平面上渲染的坐标图表,你还需要设置 xy 尺度:

    chart.x(d3.time.scale().domain([
      timeFormat.parse("2011-11-14T01:17:54Z"), 
      timeFormat.parse("2011-11-14T18:09:52Z")
    ])).elasticY(true)
    

    在这个第一种情况下,我们明确设置了 x 轴尺度,同时让图表自动为我们计算 y 尺度。而在下一个例子中,我们明确设置了 x 和 y 尺度。

    chart.x(d3.scale.linear().domain([0, 7]))
            .y(d3.scale.linear().domain([0, 12]))
    

还有更多...

不同的图表有不同的自定义外观和感觉的功能,你可以在 github.com/NickQiZhu/dc.js/wiki/API 查看完整的 API 参考文档。

利用 crossfilter.jsdc.js 可以让你快速构建复杂的数据分析仪表板。以下是对过去 20 年 NASDAQ 100 指数进行分析的演示仪表板 nickqizhu.github.io/dc.js/

还有更多...

dc.js NASDAQ 演示

在撰写这本书的时候,dc.js 支持以下图表类型:

  • 可堆叠条形图

  • 可堆叠折线图

  • 面积图(可堆叠)

  • 饼图

  • 气泡图

  • 组合图

  • 着色地图

  • 气泡叠加图

关于dc.js库的更多信息,请查看我们的 Wiki 页面 github.com/NickQiZhu/dc.js/wiki

参考阅读

以下是一些其他有用的基于 D3 的可重用图表库。尽管,与dc.js不同,它们不是原生设计用于与 Crossfilter 一起工作,但它们在应对一般的可视化挑战时往往更加丰富和灵活:

posted @ 2025-10-01 11:27  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报