D3-4-x-数据可视化秘籍-全-

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

原文:zh.annas-archive.org/md5/bb398ed715f1730961f1721f570f3b18

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

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

D3 v4 是 D3 库的最新版本。这本第二版烹饪书已经完全更新,以涵盖并利用 D3 v4 API、模块化数据结构以及改进的力实现。它旨在为你提供所有必要的指导,帮助你掌握使用 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 服务器来托管本书中一些更高级的食谱所需的数据文件。我们将在第一章中介绍如何设置基于 Node 或 Python 的简单 HTTP 服务器。

  • 如果你想直接从我们的 Git 仓库检查食谱源代码,则可选地需要一个 Git 客户端

这本书面向谁

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

部分

在这本书中,您会发现一些经常出现的标题(准备就绪、如何操作、工作原理、更多信息、以及相关内容)。

为了清楚地说明如何完成食谱,我们使用以下部分如下:

准备就绪

本节告诉您在食谱中可以期待什么,并描述了如何设置任何软件或任何为食谱所需的初步设置。

如何操作…

本节包含遵循食谱所需的步骤。

工作原理…

本节通常包含对上一节发生情况的详细解释。

更多…

本节包含有关食谱的附加信息,以便使读者对食谱有更多的了解。

相关内容

本节提供了对其他有用信息的链接,以帮助读者了解食谱。

约定

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

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“在数据库中为 JIRA 创建一个新用户,并使用以下命令授予用户对我们刚刚创建的jiradb数据库的访问权限:”

代码块设置如下:

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
});

新术语重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“从管理面板中选择系统信息。”

注意

警告或重要注意事项以如下框的形式出现。

小贴士

小技巧和技巧显示如下。

读者反馈

我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要向我们发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍标题。

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

客户支持

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

下载示例代码

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

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持标签上。

  3. 点击代码下载与勘误表

  4. 搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载

您也可以通过点击 Packt Publishing 网站上书籍网页上的代码文件按钮来下载代码文件。您可以通过在搜索框中输入书的名称来访问此页面。请注意,您需要登录到您的 Packt 账户。

文件下载完成后,请确保您使用最新版本的软件解压缩或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

本书的相关代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Data-Visualization-with-D3-4.x-Cookbook。我们还有来自我们丰富的图书和视频目录的其他代码包,可在github.com/PacktPublishing/找到。查看它们吧!

错误清单

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

要查看之前提交的错误清单,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书的名称。所需信息将在错误清单部分显示。

盗版

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

请通过版权@packtpub.com 与我们联系,并提供疑似盗版材料的链接。

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

问题和疑问

如果您对本书的任何方面有问题,您可以通过 questions@packtpub.com 与我们联系,我们将尽力解决问题。

第一章. 使用 D3.js 入门

在本章中,我们将涵盖:

  • 设置简单的 D3 开发环境

  • 设置基于 NPM 的 D3 开发环境

  • 理解 D3 风格的函数式 JavaScript

简介

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

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

D3 (数据驱动文档或 D3.js)是一个用于使用 Web 标准可视化数据的 JavaScript 库。D3 帮助您通过 SVG、Canvas 和 HTML 将数据生动呈现。D3 将强大的可视化和技术与数据驱动的 DOM 操作方法相结合,为您提供现代浏览器的全部功能以及为您的数据设计正确视觉界面的自由度。

-D3 Github Wiki (2016 年 8 月)

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

注意

若想更正式地了解 D3 背后的理念,请参阅由 Mike Bostock 在 2010 年 IEEE InfoVis 上发表的《交互可视化声明性语言设计》论文,链接为vis.stanford.edu/papers/protovis-design 。如果您想了解 D3 是如何产生的,我建议您查看 Mike Bostock、Vadim Ogievestsky 和 Jeffery Heer 在 2011 年 IEEE InfoVis 上发表的《D3: 数据驱动文档》论文,链接为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/d3/d3/tags下载存档的旧版本。另外,如果你对尝试 master 分支上的最新 D3 构建感兴趣,那么你可以 forkgithub.com/d3/d3

  2. 下载并解压后,你将在提取的文件夹中找到两个 D3 JavaScript 文件,d3.jsd3.min.js,以及其他信息文件。出于开发目的,建议你使用d3.js文件,即非压缩(最小化)版本,因为它可以帮助你在 D3 库内部跟踪和调试 JavaScript。一旦提取,将d3.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.js""></script> 
         </head> 
         <body> 
    
         </body> 
         </html> 
    
    

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

注意

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

它是如何工作的...

D3 JavaScript 库非常自给自足。它除了依赖于浏览器已经提供的 JavaScript 库外,不依赖于任何其他 JavaScript 库。

注意

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

在 D3 v4 发布之前,在头部部分包含以下字符编码指令是至关重要的,因为旧版本的 D3 在其源代码中使用了 UTF-8 符号,如π;然而,随着 D3 v4.x 的发布,这不再是必要的。然而,考虑到你将包含的其他 JavaScript 库可能也在使用 UTF-8 符号,所以这仍然被认为是一个好的实践,如下面的例子所示:

    <meta charset=""utf-8""> 

注意

D3 在其作者迈克尔·博斯特克(Michael Bostock)创建的定制许可协议下 完全开源。这个许可协议与流行的 MIT 许可证非常相似,只有一个例外,即它明确指出,未经迈克尔·博斯特克的许可,不得使用其姓名来认可或推广由此软件派生的产品。

更多内容...

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

如何获取源代码

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

注意

如果您不熟悉 Git,其克隆概念与其他版本控制软件中的检出概念类似。然而,克隆不仅简单地检出文件,还会将所有分支和版本历史复制到您的本地机器上,实际上是将整个仓库克隆到您的本地机器上,这样您就可以在完全离线的情况下使用这个克隆的仓库在自己的环境中工作。

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

注意

另一种使 Git 和 GitHub 工作起来更受欢迎的方法是安装 GitHub 客户端,它比单纯的 Git 提供了更丰富的功能。然而,在撰写本书时,GitHub 只提供了 Windows 和 Mac OS 的客户端软件;请参阅 desktop.github.com/

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

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

设置基于 NPM 的开发环境

在之前的食谱中展示的简单设置对于实现本书中的大多数食谱已经足够。然而,当您从事一个需要使用多个 JavaScript 库的更复杂的数据可视化项目时,我们之前讨论的简单解决方案可能会变得有些笨拙且难以操作。在本节中,我们将演示使用 Node Packaged ModulesNPM),一个事实上的 JavaScript 库仓库管理系统,的改进设置。如果您和我一样急切,想直接进入书籍的精华部分——食谱,您可以安全地跳过这一节,在需要为项目设置更生产就绪的环境时再回来。

准备工作

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

> npm -v 
2.15.8

前面的命令打印出 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":"4.x" 
           }, 
           "devDependencies": { 
               "uglify-js": "2.x" 
           } 
         } 
    
    
  2. 一旦定义了 package.json 文件,你只需简单地运行以下命令:

             > npm install
    
    

它是如何工作的...

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

  • dependencies 字段描述了项目在浏览器中正常运行所需的运行时库依赖项,即项目运行所需的库。

  • 在这个简单的示例中,我们只依赖 D3。d3 是在 NPM 仓库中发布的 D3 库的名称。版本号 4.x 表示该项目与任何版本 4 的发布版本兼容,NPM 应该检索最新的稳定版本 4 构建来满足这个依赖。

注意

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

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

注意

详细 NPM 包 JSON 文件文档可以在 docs.npmjs.com/files/package.json 找到。

执行 npm install 命令将自动触发 NPM 下载项目所需的所有依赖项,包括依赖项的依赖项递归下载。所有依赖库都将下载到项目根目录下的 node_modules 文件夹中。完成此操作后,你只需简单地创建一个 HTML 文件,如前一个示例所示,并直接从 node_modules/d3/build/d3.js 加载 D3 JavaScript 库。

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

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

小贴士

D3 v4.x 非常模块化;所以如果你只需要 D3 库的一部分用于你的项目,你也可以选择性地包含 D3 子模块作为你的依赖。例如,如果你只需要 d3-selection 模块在你的项目中,那么你可以在你的 package.json 文件中使用以下依赖声明:"dependencies": {       "d3-selection":"1.x" }

还有更多...

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

设置本地 HTTP 服务器

根据你使用的操作系统和你决定用作 HTTP 服务器的软件包,你可能有十几种不同的方法在你的电脑上设置 HTTP 服务器。在这里,我将尝试涵盖一些最流行的设置。

Python 简单 HTTP 服务器

这是我最喜欢的用于开发和快速原型设计的工具。如果你在你的操作系统上安装了 Python,这在任何 Unix/Linux/Mac OS 分发版中通常是默认的,那么你只需在你的终端中输入以下命令(使用 Python 2):

> python -m SimpleHTTPServer 8888

或者,使用 Python 3 分发版输入以下命令:

> python -m http.server 8888

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

注意

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

Node.js HTTP 服务器

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

首先,您需要使用以下命令安装 http-server 模块:

> npm install http-server -g

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

> http-server -p 8888

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

注意

如果您在 Linux、Unix 或 Mac OS 上运行 npm install 命令,您可能需要以 sudo 模式运行命令或作为 root 用户,以便使用 -g 全局安装选项。

理解 D3 风格的 JavaScript

D3 是使用函数式风格的 JavaScript 设计和构建的,这可能对更习惯于过程式或面向对象 JavaScript 风格的人来说有些不熟悉,甚至可能感到陌生。这个菜谱旨在涵盖 D3 所需的一些最基本的功能 JavaScript 概念,并进一步使您能够以 D3 风格编写可视化代码。

准备工作

在您的网络浏览器中打开以下文件的本地副本:github.com/NickQiZhu/d3-cookbook-v2/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) return headline; // <-- G 
         headline = h; 
         return instance; // <-- H 
       }; 

       instance.description = function (d) { 
         if (!arguments.length) return description; 
         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(); 

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

如何操作...

它是如何工作的...

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

D3 的函数式风格通过一系列组件和插件允许代码重用。 ——D3 Wiki (2016 年 8 月)

函数是对象

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

注意

在 ES6 之前,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 
  }; 

在标记为 ABC 的行中,我们可以清楚地看到 instanceheadlinedescription 都是 SimpleWidget 函数对象的内部私有变量。而 render 函数是与 instance 对象关联的函数,而 instance 对象本身被定义为对象字面量。由于函数只是对象,它们也可以存储在对象/函数中,通过变量引用,包含在数组中,以及作为函数参数传递。SimpleWidget 函数执行的结果是在行 I 返回对象实例,如下所示:

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

注意

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

静态变量作用域

好奇读者现在可能已经在问,在这个例子中变量作用域是如何解决的,因为渲染函数似乎可以访问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) return headline; // <-- G 
  headline = h; 
  return instance; // <-- H 
}; 

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

注意

事实上,arguments并不是一个真正的 JavaScript 数组对象。它有length属性,可以通过索引访问;然而,它并没有与典型 JavaScript 数组对象相关联的许多方法,例如sliceconcat。当你需要在arguments上使用标准的 JavaScript 数组方法时,你需要使用以下 apply 调用模式:var newArgs = Array.prototype.slice.apply(arguments);

当这个隐藏参数与 JavaScript 中省略函数参数的能力结合使用时,你可以编写一个像instance.headline这样的函数,它具有未指定的参数数量。在这种情况下,我们既可以有一个参数h,也可以没有。因为当没有传递参数时,arguments.length返回0,所以如果未传递参数,headline函数返回headline,如果提供了参数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行)。

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

然后,headline函数作为设置器被调用,这也返回了instance对象(如第H行)。然后可以直接在其返回值上调用description函数,这再次返回了instance对象。最后,可以调用render函数。

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

还有更多...

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

查找和分享代码

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

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

  • Christophe Viau 的 D3 画廊(christopheviau.com/d3list/gallery.html)是另一个带有分类的 D3 画廊,帮助你快速在网上找到所需的可视化示例。

  • D3 教程页面(github.com/d3/d3/wiki/Tutorials)包含了一系列由不同贡献者在不同时间创建的教程、演讲和幻灯片,详细展示了如何使用不同的 D3 概念和技术。

  • D3 插件可以在github.com/d3/d3-plugins找到。也许 D3 对于你的可视化需求缺少一些功能?在你决定实现自己的功能之前,确保查看 D3 插件仓库。它包含了许多提供可视化世界中一些常见和有时不常见功能的插件。

  • D3 API(github.com/d3/d3/blob/master/API.md)有很好的文档记录。这里你可以找到对 D3 库提供的每个函数和属性的详细解释。

  • Mike Bostok 的 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 拥有一个广泛且活跃的支持社区。简单地对您的问题进行 Google 搜索 通常可以找到令人满意的答案。即使没有,也不要担心;D3 拥有一个强大的基于社区的支援系统,如下所示:

第二章:选择

在本章中,我们将介绍:

  • 选择单个元素

  • 选择多个元素

  • 遍历选择

  • 执行子选择

  • 函数链式调用

  • 操作原始选择

简介

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

介绍选择

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

var selector = document.querySelectorAll("p"); 
selector.forEach(function(p){ 
    // do something with each element selected 
    console.log(p); 
}); 

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

CSS3 选择器基础

在我们深入探讨 D3 的选择器 API 之前,需要对 W3C 第三级选择器 API 进行一些基本介绍。如果你已经熟悉 CSS3 选择器,可以自由跳过这一部分。D3 的选择器 API 是基于第三级选择器构建的,也更为人所知的是 CSS3 选择器支持。在本节中,我们计划介绍一些理解 D3 选择器 API 所必需的常见 CSS3 选择器语法。以下列表包含了一些在数据可视化项目中通常会遇到的常见 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:选择具有goo作为class值的foo元素

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

         <foo> // <-- this one 
         <foo> 
         <foo>  
    
    
  • foo:nth-child(n):选择foo元素的第n个子元素(n是从 1 开始的,第一个子元素为 1)

         <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 的草案阶段。您可以查看它提供的内容及其当前草案drafts.csswg.org/selectors-4/

主要浏览器厂商已经开始了对第 4 级选择器的实现;如果您想了解您浏览器对选择器的支持程度,可以尝试访问这个实用的网站:css4-selectors.com/browser-selector-test/

选择单个元素

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

准备工作

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

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

如何实现...

让我们选择一些东西(比如一个paragraph元素)并在屏幕上生成经典的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。

注意

可以使用选择器选择多个元素,只要在选中时只返回第一个元素即可。

在这个例子中,我们简单地选择了target值为id的段落元素,并在第B行将其文本内容设置为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 parseFloat(d3.select(this).style('font-size')) + 
                 10 + 'px'; 
         }); 
    
    
  • 在前面的匿名函数中的变量this是选定元素<p>的 DOM 元素对象;因此,需要再次将其包裹在d3.select中,以便访问其style属性。

  • 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 content 
         d3.select("p").text(function(){ 
           return Date(); 
         }); 
    
    
  • 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").html("<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").html(function(){ 
           return d3.select(this).text() +  
             " <span style='color: blue;'>D3.js</span>"; 
         }); 
    
    

这些修改函数适用于单元素和多元素选择结果。当应用于多元素选择时,这些修改将应用于每个选定的元素。我们将在本章稍后的更复杂食谱中看到它们的实际应用。

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

选择多个元素

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

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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-v2/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 入门,其中解释了该主题。

以下列表解释了 selecteachappend 函数:

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

  • selection.append(tagName): 在本例中引入的另一个新功能是 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 元素的单一元素选择,this 变量代表该元素。一旦包装,标准 D3 选择操作 API 就可以在 d3.select(this) 上使用。之后,对当前元素选择调用 append("h1") 函数,将新创建的 h1 元素附加到当前元素上。之后,它简单地设置这个新创建的 h1 元素的文本内容为当前元素的索引号。这产生了本菜谱中所示的可视化编号框。再次提醒,索引从 0 开始,每次遇到新元素时增加 1。

注意

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

执行子选择

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

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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> 

此代码生成以下视觉输出:

如何做...

子选择

它是如何工作的...

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

  • 选择器级别-3 组合器: 在行 Ad3.select 使用了一个看起来特殊的字符串,该字符串由一个标签名和一个使用大于号(U+003E, >)连接的另一个标签名组成。这种语法称为 组合器(这里的大于号表示它是一个子组合器)。级别-3 选择器支持几种不同的结构组合器。在这里,我们将简要介绍其中最常见的一些。

  • 后代组合器: 这个组合器的语法与 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 第四级选择器提供了一些有趣的附加组合器,即后续兄弟和引用组合器,这些组合器可以提供一些非常强大的目标选择能力;更多详情请参阅 drafts.csswg.org/selectors-4/#combinators

  • D3 嵌套子选择:在行BC上,使用了不同类型的子选择技术。在这种情况下,首先在行B上通过选择section #section2元素进行简单的 D3 选择。紧接着,在行C上又进行了一次select操作来选择一个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 对您来说是一个新概念,我强烈推荐您查看由马丁·福勒在其书籍《领域特定语言》中的摘录形式提供的关于 DSL 的出色解释。摘录可以在 www.informit.com/articles/article.aspx?p=1592379 找到。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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 行,向 body 中添加了一个新的section元素。记住,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-v2/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 trSelection = d3.selectAll("tr"); // <-- A 
    var headerElement = trSelection.nodes()[0]; // <-- B 
    d3.select(headerElement).attr("class", "table-header"); // <-     
    - C 
    var rows = trSelection.nodes(); 
    d3.select(rows[1]).attr("class", "table-row-odd"); // <-- D 
    d3.select(rows[2]).attr("class", "table-row-even"); // <-- E 
    d3.select(rows[3]).attr("class", "table-row-odd"); // <-- F 
</script> 

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

如何做...

原始选择操作

它是如何工作的...

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

注意

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

在行A上,我们选择所有行并将选择存储在trSelection变量中。D3 选择有一个方便的node()函数,它返回一个包含所选元素节点的数组。因此,为了访问第一个选中的元素,你需要使用d3.selectAll("tr").nodes()[0],第二个元素可以通过d3.selectAll("tr").nodes()[1]访问,依此类推。正如我们在行B上所看到的,可以使用trSelection.nodes()[0]来访问表格的表头元素,这将返回一个 DOM 元素对象。同样,正如我们在前面的章节中所展示的,任何 DOM 元素都可以直接使用d3.select来选择,如行C所示。行DEF演示了如何直接索引和访问选择中的每个元素。

在某些情况下,原始选择访问可能很有用,尤其是当你需要与其他 JavaScript 库一起使用 D3 时,因为其他库无法与 D3 选择一起工作,而只能与原始 DOM 元素一起工作。

注意

此外,这种方法在测试环境中通常非常有用,因为在测试环境中,快速知道每个元素的绝对索引并获得它们的引用可能会很方便。我们将在稍后的章节中更详细地介绍单元测试。

在本章中,我们介绍了许多不同的方法,说明了如何使用 D3 的选择 API 来选择和操作 HTML 元素。在下一章中,我们将探讨如何将数据绑定到这样的选择上,以动态驱动选中元素的可视外观,这是数据可视化的基本步骤。

第三章。处理数据

在本章中,我们将涵盖:

  • 将数组绑定为数据

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

  • 将函数绑定为数据

  • 使用数组

  • 使用数据过滤

  • 使用数据排序

  • 从服务器加载数据

  • 使用队列进行异步数据加载

简介

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

数据由原始事实组成。单词“原始”表示这些事实尚未经过处理以揭示其含义...信息是处理原始数据以揭示其含义的结果。

*   -Rob P., S. Morris, and Coronel C. 2009*

这是在数字信息世界中传统上对数据和信息的定义。然而,数据可视化提供了对这个定义的更丰富解释,因为信息不再是仅仅处理过的原始事实的结果,而是一种事实的视觉隐喻。正如曼努埃尔·利马在他的信息可视化宣言中所说,在物质世界中,形式被视为功能的追随者。

同一个数据集可以生成任何数量的可视化,在有效性方面可能具有同等权利。在某种意义上,可视化更多的是关于传达创作者对数据的洞察,而不是其他任何事情。更有挑衅意味的是,Card、McKinlay 和 Shneiderman 提出,信息可视化的实践可以这样描述:

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

* -"Card S. and Mackinly J.", and Shneiderman B. 1999*

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

进入-更新-退出模式

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

进入-更新-退出模式

数据和视觉集

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

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

进入-更新-退出模式

更新模式

在前面的图中,阴影区域代表两个集合 AB 之间的交集。在 D3 中,可以使用 selection.data 函数来选择这个交集,A B

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

我们在这里需要回答的第二个问题是:我如何定位尚未可视化的数据点? 答案是 AB 的集合差,表示为 A\B,可以通过以下示意图直观地看到:

进入-更新-退出模式

进入模式

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

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

我们讨论的第三个案例涵盖了存在于我们的数据集中的视觉元素,但不再与任何相关联的数据元素。你可能想知道这种类型的视觉元素最初是如何存在的。这通常是由于从数据集中删除元素造成的;也就是说,如果你最初在数据集中可视化了所有数据点,并在之后删除了一些数据点。现在,你有一些视觉元素不再代表数据集中的任何有效数据点。这个子集可以使用更新差异的逆运算来发现,表示为 B\A

The enter-update-exit pattern

Exit 模式

前面的插图中的阴影区域表示我们刚才讨论的差异。可以使用数据绑定的选择上的 selection.exit 函数来选择这个子集。

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

三个不同的选择模式共同涵盖了数据与其视觉领域之间所有可能的交互情况。

此外,D3 还提供了一个第四种选择模式,当需要避免重复可视化代码或所谓的 DRY(Don't Repeat Yourself)代码时,这个模式非常有用。这种第四种模式被称为合并模式。可以使用 selection.merge 函数调用它。此函数将传递给 merge 函数的给定选择与函数被调用的选择合并,并返回一个新的选择,它是两者的并集。在 enter-update-exit 模式下,merge 函数通常用于构建一个覆盖 Enter 和 Update 模式的选择,因为那里是大多数代码重复可能存在的位置。

The enter-update-exit pattern

合并模式

本图中的阴影区域显示了由合并模式(结合了输入和更新模式)针对的目标数据点,这实际上是整个集合 A。这非常方便,因为现在可以使用单个修饰符链来样式化这两种模式,从而减少代码重复。我们将在本章的每个菜谱中展示如何利用合并模式。

注意

在软件工程中,不要重复自己DRY)是一个软件开发原则,旨在减少各种信息重复(维基百科,2016 年 8 月)。你还可以阅读 Mike Bostock 关于什么使软件变得好?的帖子,以了解更多关于这种设计变化背后的原因。medium.com/@mbostock/what-makes-software-good-943557f8a488#.l640c13rp .

输入-更新-退出模式是任何由 D3 驱动的可视化的基石。在本章的后续菜谱中,我们将涵盖如何有效地利用这些选择方法来生成数据驱动的视觉元素。

将数组绑定为数据

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

准备工作

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

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

如何做...

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

var data = [10, 15, 30, 50, 80, 65, 55, 30, 20, 10, 8]; // <- A 
    function render(data) { // <- B 
        var bars = d3.select("body").selectAll("div.h-bar") // <- C 
                .data(data); // Update <- D 
        // Enter 
        bars.enter() // <- E 
                .append("div") // <- F 
                    .attr("class", "h-bar") // <- G 
            .merge(bars) // Enter + Update <- H 
                .style("width", function (d) { 
                    return (d * 3) + "px"; // <- I 
                }) 
                .text(function (d) { 
                    return d; // <- J 
                }); 
        // Exit 
        bars.exit() // <- K 
                .remove(); 
    } 
    setInterval(function () { // <- L 
        data.shift(); 
        data.push(Math.round(Math.random() * 100)); 
        render(data); 
    }, 1500); 
    render(data); 

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

如何做...

数据作为数组

它是如何工作的...

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

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

在第 F 行,创建了一个新的 div 元素并将其附加到 enter 函数中选中的每个数据元素的 body 元素上;这实际上为每个数据创建了一个 div 元素。最后,在第 G 行,我们将它的 CSS 类设置为 h-bar。到这一点,我们基本上创建了可视化的大纲,包括空的 div 元素。下一步是根据给定数据更改元素的可视属性。

小贴士

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

如何工作...

如前面的截图所示,这在调试时非常有用。

在下面的代码片段中,在第 H 行调用了合并函数,并将选择项作为其参数。这个函数调用实际上是将进入选择项与更新选择项合并,并返回两个选择项的并集,从而允许我们为进入和更新场景链式调用修饰符。如果没有合并函数,我们需要为进入和更新场景重复这段代码。然后,在第 I 行,我们应用了一个动态样式属性 width,其值是每个视觉元素关联的整数值的三倍,如下面的代码片段所示:

        bars.enter() // <- E 
                .append("div") // <- F 
                    .attr("class", "h-bar") // <- G 
            .merge(bars) // Enter + Update <- H 
                .style("width", function (d) { 
                    return (d * 3) + "px"; // <- I 
                }) 
                .text(function (d) { 
                    return d; // <- J 
                }); 

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

注意

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

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

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

以下最后部分,即“退出”部分,相当简单:

bars.exit() // <- K 
    .remove(); 

注意

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

在前面的代码片段的第K行,调用了exit()函数来计算不再与任何数据关联的所有视觉元素集合的差集。最后,在这个选择集上调用remove()函数来移除由exit()函数选中的所有元素。这样,只要您在更改我们的数据后调用render()函数,您就可以始终确保我们的视觉表示和数据保持同步。

现在,让我们按照以下方式实现最后一段代码:

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

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

将对象字面量绑定到数据

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

准备工作

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

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

如何实现...

当你在网络上加载数据源时,JavaScript 对象字面量可能是你遇到的最常见的数 据结构。在本示例中,我们将探讨如何利用这些 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.scaleLinear() 
        .domain([0, 100]) 
        .range(["#add8e6", "blue"]); // <- B 
    function render(data) { 
        var bars = d3.select("body").selectAll("div.h-bar") 
                .data(data); // Update 
        // Enter 
        bars.enter() 
                .append("div") 
                .attr("class", "h-bar") 
                .merge(bars) // Enter + Update 
                .style("width", function (d) { // <- C 
                    return (d.width * 5) + "px"; // <- D 
                }) 
                .style("background-color", function(d){ 
                    return colorScale(d.color); // <- E 
                }) 
                .text(function (d) { 
                    return d.width; // <- F 
                }); 
        // Exit 
        bars.exit().remove(); 
    } 
    function randomValue() { 
        return Math.round(Math.random() * 100); 
    } 
    setInterval(function () { 
        data.shift(); 
        data.push({width: randomValue(), color: randomValue()}); 
        render(data); 
    }, 1500); 
    render(data); 

本示例生成了以下可视化:

如何实现...

数据作为对象

注意

本示例建立在之前的示例之上,所以如果你不熟悉基本的 enter-update-exit 选择模式,请先查看之前的示例。

工作原理...

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

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

注意

在第 B 行,我们定义了一个看起来很复杂的 color 比例:... .range(["#add8e6", "blue"]); // <- B ... 包括颜色比例在内的比例将在下一章中深入讨论,所以现在让我们假设这是一个我们可以使用它来生成 CSS 兼容颜色代码的比例函数,给定一些整数输入值。这对于本示例的目的来说已经足够了。

与上一个示例相比,主要区别在于处理数据的方式,如下面的代码片段中第 C 行所示:

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

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

注意

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

同样,在下面的代码片段中第 E 行,可以使用我们之前定义的颜色比例的 d.color 属性来计算 background-color 样式:

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

再次,在第 F 行,我们将每个条形的文本设置为显示其宽度。

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

将函数作为数据绑定

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

准备工作

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

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

如何操作...

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

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

<script type="text/javascript"> 
    var data = []; // <- A 
    var datum = function (x) { // <- B 
        return 15 + x * x; 
    }; 

    var newData = function () { // <- C 
        data.push(datum); 
        return data; 
    }; 
    function render(){ 
        var divs = d3.select("#container") 
                    .selectAll("div") 
                    .data(newData); // <- D 
        divs.enter().append("div").append("span"); 
        divs.attr("class", "v-bar") 
            .style("height", function (d, i) { 
                return d(i) + "px"; // <- E 
            }) 
            .select("span") // <- F 
                .text(function(d, i){  
                    return d(i); // <- G 
                }); 
        divs.exit().remove(); 
    } 

    setInterval(function () { 
        render(); 
    }, 1000); 
    render(); 
</script> 

以下代码生成了以下条形图:

如何操作...

数据作为函数

它是如何工作的...

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

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

    var data = []; // <- A 
    var datum = function (x) { // <- B 
        return 15 + x * x; 
    }; 

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

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

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

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

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

作为函数的引用,d现在可以用索引i作为参数调用,这将生成我们可视化所需的公式输出。

注意

在 JavaScript 中,函数是特殊对象,因此从语义上讲,这与绑定对象作为数据完全相同。此外,数据也可以被视为函数。例如,整数等常量值可以被视为一个恒等函数,它只是返回它接收的内容,而不进行任何修改。

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

注意

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

处理数组

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

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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("#quantile").text( 
            d3.quantile(array.sort(d3.ascending), 0.25) 
    ); 
    d3.select("#deviation").text(d3.deviation(array)); 
    d3.select("#asc").text(array.sort(d3.ascending)); 
    d3.select("#desc").text(array.sort(d3.descending)); 
    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, "")); 

    // Utility function to generate HTML  
    // representation of nested tip data  
    function printNest(nest, out, i) { 
        """""""" 

    }"""""""" 
</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.deviation(array) => 4.18 
d3.bisect(array.sort(d3.ascending), 6) => 4 

tab 
 100 
  {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, } 
visa 
  200 
   {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.median:此函数找到中位数,即7

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

  • d3.ascending/d3.descendingd3对象包含一个内置的比较函数,您可以使用它来对 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.deviation:此函数计算数组的标准差,在我们的例子中将是4.18

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

  • d3.nest:D3 的nest函数可以用来构建一个算法,将基于平铺数组的结构转换为适合某些类型可视化的层次嵌套结构。D3 的nest函数可以通过连接到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行上显示的基于平铺数组的数据集。

数据过滤

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

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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) { 
        var bars = d3.select("body").selectAll("div.h-bar") // <-B 
                .data(data); 
        // Enter 
        bars.enter() 
            .append("div") // <-C 
                .attr("class", "h-bar") 
                .style("width", function (d) { 
                    return (d.expense * 5) + "px";} 
                ) 
                .append("span") // <-D 
                .text(function (d) { 
                    return d.category; 
                }); 
        // Update 
        d3.selectAll("div.h-bar").attr("class", "h-bar"); 
        // Filter 
        bars.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> 

当您点击Dining按钮时,前面的代码将生成以下视觉输出:

如何做...

数据过滤

工作原理...

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

bars.filter(function (d, i) { // <-E 
    return d.category == category; 
}).classed("selected", true); 

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

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

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

  • this:这具有对当前 DOM 元素的隐藏引用点

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

注意

D3 的selection.filter函数将返回的值作为 JavaScript 中的真值假值测试处理,因此并不期望严格的布尔值。这意味着falsenull0""undefinedNaN(非数字)都被视为false,而其他东西被认为是true

数据排序

在许多情况下,根据所代表的数据对视觉元素进行排序是可取的,这样你可以通过视觉方式突出不同元素的重要性。在本食谱中,我们将探讨如何在 D3 中实现这一点。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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) { 
        var bars = d3.select("body").selectAll("div.h-bar") // <-B 
                .data(data); 
        // Enter 
        bars.enter().append("div") // <-C 
                .attr("class", "h-bar") 
                .append("span"); 
        // Update 
        d3.selectAll("div.h-bar") // <-D 
                .style("width", function (d) { 
                    return (d.expense * 5) + "px"; 
                }) 
                .select("span") 
                .text(function (d) { 
                    return d.category; 
                }); 
        // Sort 
        if(comparator) 
            bars.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 Expense 
    </button> 
    <button onclick="sort(compareByCategory)"> 
        Sort by Category 
    </button> 
    <button onclick="sort()"> 
        Reset 
    </button> 
</div> 

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

如何操作...

基于数据的排序

工作原理...

在本食谱中,我们设置了一个简单的基于行的可视化(在BCD行),包含两个属性expensecategory,这些属性在A行定义。这几乎与之前的食谱完全相同,并且与我们之前在绑定对象字面量作为数据食谱中所做的非常相似。一旦完成基本操作,我们就在E行选择所有现有的条形,并使用 D3 的selection.sort函数进行排序:

        // Sort 
        if(comparator) 
            bars.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; 
}; 

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

注意

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

从服务器加载数据

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

准备工作

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

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

如何操作...

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

<div id="chart"></div> 

<script type="text/javascript"> 
    function render(data) { 
        var bars = d3.select("#chart").selectAll("div.h-bar") // <-A 
                .data(data); 
        bars.enter().append("div") // <-B 
            .attr("class", "h-bar") 
                .style("width", function (d) { 
                    return (d.expense * 5) + "px"; 
                }) 
            .append("span") 
                .text(function (d) { 
                    return d.category; 
                }); 
    } 
    function load(){ // <-C 
        d3.json("data.json", function(error, json){ // <-D 
            render(json); 
        }); 
    } 
</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 源加载数据按钮一次后,此菜谱将生成以下视觉输出:

如何操作...

从服务器加载数据

工作原理...

在本菜谱中,我们创建了一个render函数来生成一个基于水平条的可视化,这与我们在上一两个菜谱中所做的工作非常相似。load函数定义在行C,它响应用户点击从 JSON 源加载数据按钮,从服务器加载来自单独文件(data.json)的数据。这是通过行F上显示的d3.json函数实现的:

    function load(){ // <-C 
        d3.json("data.json", function(error, json){ // <-D 
            render(json); 
        }); 
    } 

由于从 JSON 文件加载远程数据集可能需要一些时间,因此它是异步执行的。一旦加载完成,数据集将被传递给在行D上定义的匿名回调函数。在这个函数中,我们只需将新加载的数据集传递给render函数,以便生成可视化。

注意

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

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

注意

MIME 媒体类型是互联网上传输的文件格式的两部分标识符。常见的注册顶级类型包括:应用程序、文本、音频、图像、视频。

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

使用队列进行异步数据加载

在这个菜谱中,我们将展示另一种在大型数据可视化项目中常用且非常有用的技术,用于处理或生成数据。在复杂的可视化项目中,通常在可视化之前,有必要从不同的来源加载和合并多个数据集。这种异步加载的挑战在于难以知道何时所有数据集都已成功加载,因为只有在那时可视化才能开始。D3 提供了一个非常方便的 queue 接口来帮助组织这些类型的异步任务,并帮助您协调它们,这也是本菜谱的焦点。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter3/queue.html .

如何做...

queue.html 文件的代码示例中,我们将使用 setTimeout 函数模拟加载和合并多个数据点。setTimeout 函数在设置的一段时间延迟后执行给定的函数;在我们的情况下,我们将延迟设置为 500 毫秒:

<div id="chart"></div> 

<script type="text/javascript"> 
    function render(data) { 
        var bars = d3.select("#chart").selectAll("div.h-bar") // <-B 
                .data(data); 
        bars.enter().append("div") // <-C 
                .attr("class", "h-bar") 
                .style("width", function (d) { 
                    return (d.number) + "px"; 
                }) 
                .append("span") 
                .text(function (d) { 
                    return d.number; 
                }); 
    } 
    function generateDatum(callback) { 
        setInterval(function(){ 
            callback(null, {number: Math.ceil(Math.random() * 500)}); // <-D 
        }, 500); 
    } 
    function load() { // <-E 
        var q = d3.queue(); // <-F 
        for (var i = 0; i < 10; i++) 
            q.defer(generateDatum); // <-G 
        q.awaitAll(function (error, data) { // <-H 
            render(data); // <- I 
        }); 
    } 
</script> 

<div class="control-group"> 
    <button onclick="load()">Generate Data Set</button> 
</div> 

在点击生成数据集按钮后,此菜谱将生成以下输出:

如何做...

使用 D3 队列进行异步数据生成

它是如何工作的...

在这个菜谱中,我们有一个相当标准的 render 函数,它使用标准的 enter-update-exit 模式生成水平条形可视化,如第 B 行和 C 行所示。到目前为止,这个模式应该非常熟悉。然而,数据生成部分,这也是我们在这里关注的焦点,在这个菜谱中略有不同。在第 D 行,我们有一个简单的随机数据生成函数 generateDatum(callback),它接收一个参数 callback。这是 D3 队列接口中任务函数的一个非常标准的模板,如下面的代码片段所示:

function generateDatum(callback) { 
        setInterval(function(){ 
            callback(null, {number: Math.ceil(Math.random() * 500)}); // <-D 
        }, 500); 
} 

在这个函数中,我们使用 setInterval 函数以 500 毫秒的延迟模拟异步数据生成。每个任务函数可以在其主体中执行任意逻辑和计算,例如异步加载数据或计算结果。然而,一旦任务完成,它必须调用回调函数来通知队列它已完成其任务,并传递回结果,如第 D 行所示。回调函数接受两个参数:错误和结果;在这种情况下,我们传递 null 作为错误信号,因为它是通过第二个参数中的随机数成功完成的。在第 E 行,我们定义了 load 函数,该函数利用 d3.queue 来执行任务。让我们更详细地看看 load 函数:

    function load() { // <-E 
        var q = d3.queue(); // <-F 
        for (var i = 0; i < 10; i++) 
            q.defer(generateDatum); // <-G 
            q.awaitAll(function (error, data) { // <-H 
            render(data); // <- I 
        }); 
    } 

可以使用 d3.queue 函数创建 D3 队列,如第 F 行所示。一旦创建,它可以使用 defer 函数注册任意数量的任务,如第 G 行所示。在我们的例子中,我们使用 for 循环在我们的队列中注册了 10 个异步随机数据生成任务,如第 G 行所示。

注意

D3 队列内部不提供多线程,如 Web Worker 所提供的。所有任务都是同步处理的;然而,任务函数可以执行,并且通常设计为执行异步任务,正如我们在这里所展示的。有关 Web Worker 的更多信息,请参阅 developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers

在第 H 行显示的 d3.queue.awaitAll 函数用于等待所有任务完成。传递给 awaitAll 函数的回调函数将在所有任务完成或发生错误时(仅捕获并传递给回调的第一个错误)被调用。在我们的例子中,我们必须等待所有 10 个随机数据点成功生成后,才能调用渲染函数在第 I 行生成可视化。

提示

d3.queue 函数还接受一个参数来定义执行任务时允许的最大并发数。如果没有提供,则不对并发数进行限制。

在本章中,我们介绍了使用 D3 的基本方面——将数据绑定到视觉元素以及如何保持它们的同步。在此基础上,我们还涵盖了数据加载和处理的各种主题。在下一章中,我们将向读者介绍 D3 的另一个基本概念——刻度(scales),它为许多其他高级 D3 功能提供支持,例如动画和形状生成器等。

第四章. 权衡轻重

在本章中,我们将涵盖:

  • 使用连续尺度

  • 使用时间尺度

  • 使用序数尺度

  • 插值字符串

  • 插值颜色

  • 插值复合对象

简介

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

尺度是什么?

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

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

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

* -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-v2/blob/master/src/chapter4/continuous-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.scaleLinear() // <-A 
        .domain([1, 10]) // <-B 
        .range([1, 10]); // <-C         
    var linearCapped = d3.scaleLinear() 
        .domain([1, 10])         
        .range([1, 20]); // <-D 

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

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

    function render(data, scale, selector) { 
        d3.select(selector).selectAll("div") 
                    .data(data) 
                .enter() 
                .append("div") 
                    .classed("cell", true) 
                    .style("display", "inline-block") 
                    .text(function (d) { 
                        return d3.format(".2")(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 提供的一些最常见的比例尺。

线性比例尺

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

    var linear = d3.scaleLinear() // <-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.scaleLinear() 
        .domain([1, 10])         
        .range([1, 20]); // <-D 

线性比例尺

线性比例尺

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

注意

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

功率尺度

我们创建的第二个尺度是一个功率尺度。在第E行,我们定义了一个具有指数2的功率尺度。d3.scalePow()函数返回一个默认的功率尺度函数,其指数设置为1。这个尺度有效地定义了函数 f(n) = n²

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

功率尺度

简单功率尺度

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

第二个功率尺度定义了以下函数,该函数在函数之后的代码中进行了演示:

f(n) = an² + b, 1 <= f(n) <= 10*

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

功率尺度

功率尺度

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

对数尺度

在第H行,使用d3.scaleLog()函数创建了一种第三种类型的定量尺度。默认的对数尺度有一个base10。第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.scaleLog() // <-I 
        .domain([1, 10]) 
        .rangeRound([1, 10]); 

对数尺度

对数尺度

使用时间尺度

通常,我们将在具有时间和日期维度的数据集上创建可视化;因此,D3 提供了一个内置的时间尺度来帮助执行此类映射。在这个菜谱中,我们将学习如何使用 D3 时间尺度。

准备工作

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

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

如何操作...

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

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

<script type="text/javascript"> 
    var start = new Date(2016, 0, 1), // <-A 
        end = new Date(2016, 11, 31), 
        range = [0, 1200], 
        time = d3.scaleTime().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) 
                        .style("margin-left", function(d){ // <-F 
                            return scale(d) + "px"; 
                        }) 
                        .html(function (d) { // <-G 
                            var format = d3.timeFormat("%x"); // <-H 
                            return format(d) + "<br>" + scale(d) + "px"; 
                        }); 
    } 

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

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

如何操作...

时间尺度

它是如何工作的...

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

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

注意

JavaScript Date 对象从 0 开始月份,从 1 开始日期。因此,new Date(2016, 0, 1) 将表示 2016 年 1 月 1 日,而 new Date(2016, 0, 0) 实际上表示 2015 年 12 月 31 日。

然后,我们使用 d3.scaleTime 函数在行 B 上创建了一个基于此范围的 D3 时间尺度。类似于其他连续尺度,时间尺度也支持单独的 domainrange 定义,用于将基于日期和时间的点映射到视觉范围。在这个例子中,我们将尺度的范围设置为 [0, 900]。这有效地定义了从 2016 年 1 月 1 日到 2016 年 12 月 31 日之间的任何日期和时间值到 0 到 900 之间的映射。

在定义了时间尺度之后,我们现在可以通过调用尺度函数来映射任何给定的 Date 对象,例如,time(new Date(2016, 4, 1)) 将返回 395time(new Date(2016, 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); 
    } 

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

要水平展开单元格,行 F 执行从月份到我们定义的时间尺度的 margin-left CSS 样式的映射:

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

G 生成的标签展示了在这个例子中基于尺度映射产生的结果:

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

要从 JavaScript Date 对象生成可读的字符串,我们在行 H 上使用了 D3 时间格式化器,它是 d3.locale.format 函数的别名。D3 随带一个强大且灵活的时间格式化库作为区域格式库的一部分,这在处理 Date 对象时非常有用。

还有更多...

以下是一些最有用的 d3.locale.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: 这表示带世纪的年份,以十进制数表示

参见

使用序数尺度

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

准备工作

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

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

如何操作...

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

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

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

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

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

    function render(data, scale, selector) { // <-C 
        var cells  = d3.select(selector).selectAll("div.cell") 
                .data(data); 

        cells.enter() 
                .append("div") 
                    .classed("cell", true) 
                    .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.scaleOrdinal(d3.schemeCategory10),  
                                      "#category10"); 
render(data, d3.scaleOrdinal(d3.schemeCategory20),  
                                      "#category20"); 
render(data, d3.scaleOrdinal(d3.schemeCategory20b),  
                                      "#category20b"); 
render(data, d3.scaleOrdinal(d3.schemeCategory20c),  
                                      "#category20c"); // <-G 
</script> 

前面的代码在您的浏览器中输出以下内容:

如何操作...

序数尺度

它是如何工作的...

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

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

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

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

在第C行,定义了render函数以在页面上生成多个div元素来表示数据数组中的 10 个元素。每个div元素都将其background-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行,使用alphabet序数尺度调用render将产生以下输出:

它是如何工作的...

字母序数尺度

当在第G行时,使用内置的d3.scaleOrdinal(d3.schemeCategory20c)序数颜色类别方案调用render函数会产生以下输出:

它是如何工作的...

颜色序数尺度

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

提示

构建自己的简单自定义序数颜色比例相当容易。只需创建一个范围设置为所需颜色的序数比例,如下例所示:d3.scaleOrdinal() .range(["#1f77b4", "#ff7f0e", "#2ca02c"]);

插值字符串

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

在这个菜谱中,我们将探讨如何使用 D3 比例和插值来实现这一点。然而,在我们直接进入字符串插值之前,我们需要一些关于插值器的基础知识,接下来的部分将涵盖插值的基本概念以及 D3 如何实现插值函数。

插值器

在前三道菜谱中,我们讨论了三种不同的 D3 比例实现;现在是我们深入探讨 D3 比例的时候了。你可能已经在问自己,不同的比例是如何知道对不同输入使用什么值的? 实际上,这个问题可以通过以下插值器的定义进行概括:

我们给出了函数 f(x)在不同点 x0, x1, ... ,xn 处的值。我们想要找到函数 f(x)在“新”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函数返回一个插值函数,我们可以使用它来执行基于数字的插值。插值函数等同于以下代码:

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

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

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

注意

关于数字和圆滑插值的更多细节,请参阅github.com/d3/d3/blob/master/API.md#interpolators-d3-interpolate中的 D3 参考文档。

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

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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.scaleLinear() // <-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 
        var cells = d3.select(selector).selectAll("div.cell") 
                .data(data); 

        cells.enter() 
            .append("div") 
                .classed("cell", true) 
                .style("display", "inline-block") 
            .append("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,这是我们在这个菜谱中想要插值的。此时,你可能会问线性比例尺函数是如何将数字域映射到这些任意的字体 CSS 样式的。默认情况下,线性比例尺使用 d3.interpolateString 函数来处理基于字符串的范围。d3.interpolateString 函数将尝试识别给定字符串中的嵌入数字,在我们的例子中,是字体大小数字,并且只对这些数字进行插值。因此,在这个菜谱中,我们实际上使用线性比例尺将我们的域映射到字体大小。

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

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

如我们所见,只需将字体样式设置为 scale(d) 的输出就足够了,因为函数输出是一个完整的字体 CSS 样式字符串,其中包含转换后的嵌入数字。

注意

如果你检查这个菜谱的输出,你会注意到输出的 CSS 样式实际上比我们使用的原始样式字符串要长。输出看起来像这样:font-style: italic; font-variant: normal; font-weight: bold; font-stretch: normal; font-size: 90.5455px; line-height: 139.091px; font-family: Georgia, serif; 这是因为 D3 CSS 转换首先解析 CSS 样式,然后使用浏览器计算的全限定 CSS 字符串进行插值。这样做是为了避免一些可能由直接插值引起的微妙错误。

还有更多...

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

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

注意

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

颜色插值

当你需要插值不包含数字而包含 RGB 或 HSL 颜色代码的值时,有时需要插值颜色。本食谱解决了以下问题:如何定义颜色代码的刻度并在其上进行插值?

准备工作

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

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

如何做...

颜色插值在可视化中是一项非常常见的操作,因此 D3 实际上提供了一系列不同类型的插值器,专门用于支持颜色,例如RGBHSLLabHCLCubehelix*颜色空间。在本食谱中,我们将演示如何在 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.scaleLinear() // <-A 
        .domain([0, max]) 
        .range(["white", "#4169e1"]); 

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

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

    function render(data, scale, selector) { // <-D 
        var cells = d3.select(selector).selectAll("div.cell") 
                .data(data); 

        cells.enter() 
            .append("div").merge(cells) 
                .classed("cell", true) 
                .style("display", "inline-block") 
                .style("background-color", function(d){ 
                    return scale(d); // <-E 
                }) 
                .text(function(d,i){return i;}); 
    } 

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

<div class="control-group clear"> 
    <button onclick="render(data, divergingScale(5), '#color-diverge')">Pivot at 5</button> 
    <button onclick="render(data, divergingScale(10), '#color-diverge')">Pivot at 10</button> 
    <button onclick="render(data, divergingScale(15), '#color-diverge')">Pivot at 15</button> 
    <button onclick="render(data, divergingScale(20), '#color-diverge')">Pivot at 20</button> 
</div> 

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

如何做...

颜色插值

它是如何工作的...

本食谱的第一步是在第A行定义一个线性颜色刻度,其范围设置为["white", "#4169e1"]

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

注意

正如我们之前所展示的,D3 颜色插值器在处理颜色空间方面非常智能。类似于你的浏览器,它理解颜色关键字和十六进制值。

在这个食谱中使用的一种新技术,我们之前还没有遇到过,就是多线性刻度,它在divergingScale函数的第B行定义,如下所示:

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

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

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

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

相关内容

插值复合对象

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

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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.scalePow() 
            .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 
        var bars = d3.select(selector).selectAll("div.v-bar") 
                .data(data); 
        bars.enter() 
                .append("div") 
                .classed("v-bar", true) 
                .style("height", function(d){ // <-D 
                        return scale(d).height; 
                    }) 
                .style("background-color", function(d){ // <-E 
                    return scale(d).color; 
                }) 
                .text(function(d,i){return i;}); 
    } 

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

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

如何做...

复合对象插值

它是如何工作的...

与本章前面的菜谱不同,本菜谱中我们将使用两个对象定义的范围而不是简单的原始数据类型来定义我们将使用的比例:

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

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

注意

内部,D3 使用 d3.interpolateObject 函数递归地插值一个对象;该算法的递归性质允许 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 元素的高度渲染为 15pxvar compoundScale = d3.scalePow()             .exponent(2)             .domain([0, max])                  range([                  {color:"#add8e6", height:"15px"}, // <-A                  {color:"#4169e1"} // <-B             ]);

在本章中,我们介绍了 D3 中的一个重要基本概念——刻度。在下一章中,我们将继续探讨本书中第一个基于刻度的可视化组件——坐标轴。

第五章。玩转坐标轴

在本章中,我们将涵盖:

  • 使用基本坐标轴

  • 自定义刻度

  • 绘制网格线

  • 坐标轴的动态缩放

简介

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

使用基本坐标轴

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

准备工作

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

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

如何操作...

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

<div class="control-group"> 
    <button onclick="renderAll(d3.axisBottom)"> 
        horizontal bottom 
    </button> 
    <button onclick="renderAll(d3.axisTop)"> 
        horizontal top 
    </button> 
    <button onclick="renderAll(d3.axisLeft)"> 
        vertical left 
    </button> 
    <button onclick="renderAll(d3.axisRight)"> 
        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(fn, scale, i){ 
        var axis = fn() // <-D 
            .scale(scale) // <-E 
            .ticks(5); // <-G 

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

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

        createSvg(); 

        renderAxis(fn, d3.scaleLinear() 
                    .domain([0, 1000]) 
                    .range([0, axisWidth]), 1); 
        renderAxis(fn, d3.scalePow() 
                    .exponent(2) 
                    .domain([0, 1000]) 
                    .range([0, axisWidth]), 2); 
        renderAxis(fn, d3.scaleTime() 
                    .domain([new Date(2016, 0, 1),  
                             new Date(2017, 0, 1)]) 
                    .range([0, axisWidth]), 3); 
    } 
</script> 

之前的代码只显示了以下截图中的四个按钮的视觉输出;当你点击水平底部时,它会显示以下内容:

如何操作...

水平坐标轴

以下截图显示了当你点击垂直右侧按钮时的样子:

如何操作...

垂直坐标轴

它是如何工作的...

本食谱的第一步是创建svg元素,它将被用来渲染我们的坐标轴。这是通过createSvg函数完成的,该函数定义在第A行,以及 D3 的appendattr修改函数,如第B行和第C行所示。

注意

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

让我们看看以下代码片段中我们是如何创建 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 坐标轴生成函数创建一个坐标轴组件。D3 4.x 版本提供了四个内置的坐标轴生成器,用于不同的方向。方向告诉 D3 给定坐标轴将如何放置,因此应该如何渲染;例如,是水平还是垂直。D3 支持的四个内置坐标轴方向如下:

  • d3.axisTop: 一个水平轴,标签放置在轴的顶部

  • d3.axisBottom: 一个水平轴,标签放置在轴的底部

  • d3.axisLeft: 一个垂直轴,标签放置在轴的左侧

  • d3.axisRight: 一个垂直轴,标签放置在轴的右侧

您可以在以下代码片段中看到,这些确实是当您点击指定的按钮时传递给 renderAll 函数的函数:

<div class="control-group"> 
    <button onclick="renderAll(d3.axisBottom)"> 
        horizontal bottom 
    </button> 
    <button onclick="renderAll(d3.axisTop)"> 
        horizontal top 
    </button> 
    <button onclick="renderAll(d3.axisLeft)"> 
        vertical left 
    </button> 
    <button onclick="renderAll(d3.axisRight)"> 
        vertical right 
    </button> 
</div> 
... 
function renderAxis(fn, scale, i){ 
        var axis = fn() // <-D 
            .scale(scale) // <-E 
            .ticks(5); // <-G 
... 

D3 轴被设计为与 D3 尺度无缝工作。轴尺度是通过 scale() 函数提供的(参考行 E)。在这个例子中,我们使用以下尺度渲染了三个不同的轴:

d3.scaleLinear().domain([0, 1000]).range([0, axisWidth]) 
d3.scalePow().exponent(2).domain([0, 1000]).range([0, axisWidth]) 
d3.scaleTime() 
  .domain([new Date(2016, 0, 1), new Date()]) 
  .range([0, axisWidth]) 

在行 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 变换将在第七章(Chapter 7)Getting into Shape 中详细讨论,或者您可以参考以下 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/d3/d3-selection/blob/master/README.md#selection_call

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

工作原理...

水平底部时间轴 SVG 结构

自定义刻度

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

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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.scaleLinear() 
            .domain([0, 1]).range([0, axisWidth]); 

    var axis = d3.axisBottom() 
            .scale(scale) 
            .ticks(10) 
            .tickSize(12) // <-A 
            .tickPadding(10) // <-B 
            .tickFormat(d3.format(".0%")); // <-C 

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

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

如何实现...

自定义轴刻度

工作原理...

本食谱的重点是 ticks 函数之后的突出显示的行。正如我们之前提到的,ticks 函数为 D3 提供了一个提示,说明轴应该包含多少个刻度。在设置刻度数量之后,在本食谱中,我们继续通过进一步的函数调用来进一步自定义刻度。在行 A 上,使用 tickSize 函数来自定义刻度的大小。D3 提供的默认刻度大小是 6px,而在这个例子中我们将其设置为 12px。然后,在行 B 上,使用 tickPadding 函数指定刻度标签和轴之间的空间(以像素为单位)。

最后,在行 C 上使用 tickFormat 函数自定义了格式,将刻度值转换为百分比。D3 轴的 tickFormat 函数也可以接受一个函数作为格式化器进行更多自定义,因此,本食谱中使用的格式化器与以下自定义格式函数相同:

.tickFormat(function(v){ // <-C 
    return Math.floor(v * 100) + "%"; 
}); 

注意

关于上述函数和其他刻度相关自定义的更多信息,请访问以下 URL 的 D3 Wiki:github.com/d3/d3-axis/blob/master/README.md#_axis

绘制网格线

很频繁地,我们需要绘制与xy轴上的刻度一致的水平和垂直网格线。正如我们在前面的配方中所示,通常情况下,我们并没有,或者不希望对 D3 轴上刻度的渲染有精确的控制。因此,在它们被渲染之前,我们可能不知道有多少刻度以及它们的值。这在您正在构建一个可重用的可视化库时尤其如此,因为在那时之前,您不可能知道刻度配置。在这个配方中,我们将探讨一些在轴上绘制一致网格线的技术,而实际上并不需要知道刻度值。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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.scaleLinear() 
                        .domain([0, 100]) 
                        .range([0, axisLength]); 

        var xAxis = d3.axisBottom() 
                .scale(scale); 

        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.scaleLinear() 
                        .domain([100, 0]) 
                        .range([0, axisLength]); 

        var yAxis = d3.axisLeft() 
                .scale(scale); 

        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) // <-F 
                .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属性定义了终点

在我们的例子中,我们只需要将 x1y1x2 设置为 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.ticksvg:g 容器元素中。

y 轴的网格线使用的是相同的技巧生成;唯一的区别是,我们不是在网格线上设置 y2 属性,就像我们在 x 轴上做的那样,而是改变 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-v2/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.scaleLinear() 
                        .domain([0, 100]) 
                        .range([0, xAxisLength]); 

        xAxis = d3.axisBottom() 
                .scale(scale); 

        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 

        yAxis.scale().domain([max, 0]); 
        svg.select("g.y-axis") 
            .transition() 
            .call(yAxis); 

        renderXGridlines(); 
        renderYGridlines(); 
    }        

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

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

    ... 

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

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

如何操作...

动态轴缩放

注意

由于本书篇幅有限,本食谱中的代码示例省略了与 y 轴相关的代码。请参考在线可用的代码示例以获取完整参考。

如何工作...

一旦您在屏幕上点击 ReScale 按钮,您将注意到轴会缩放,同时所有刻度和网格线都会随着平滑的过渡效果重新绘制。在本节中,我们将关注这种缩放是如何工作的,并将过渡效果留到下一章,带有风格的过渡。本食谱的大部分工作都是由第 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(){ 
        d3.selectAll("g.x-axis g.tick") 
                .select("line.grid-line") 
                .remove(); // <-D 

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

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

第六章。以风格进行过渡

上一段代码产生了以下视觉输出,其中出现了一个框,在本章中,我们将涵盖:

  • 动画单个元素

  • 动画多个元素

  • 使用缓动

  • 使用缓动

  • 使用过渡链

  • 使用过渡过滤器

  • 监听过渡事件

  • 与计时器一起工作

简介

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

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

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

-Parent R. 2012

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

什么是过渡?

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

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

动画单个元素

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

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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> 

上述代码产生了一个移动、缩小和颜色变化的正方形,如下面的截图所示:

如何做...

单个元素过渡

它是如何工作的...

你可能会惊讶地发现,我们添加以启用此动画的额外代码仅在行 CD 上,如下面的代码片段所示:

body.append("div") // <-A 
            .classed("box", true) 
            .style("background-color", "#e9967a") // <-B 
            .transition() // <-C 
            .duration(duration) // <-D 

首先,在行 C 上,我们调用 d3.selection.transition 函数来定义一个过渡。然后,transition 函数返回一个过渡绑定的选择,它仍然代表当前选择中的相同元素(s)。然而,现在它配备了额外的功能,并允许进一步自定义过渡行为。

在行 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-v2/blob/master/src/chapter6/multi-element-transition.html

如何实现...

如预期的那样,这个菜谱比上一个稍微大一些,但并不是很大。让我们看看以下代码:

<script type="text/javascript"> 
var id= 0, 
data = [], 
duration = 500, 
chartHeight = 100, 
chartWidth = 680; 

for(vari = 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("z-index", "0") 
                .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) 
    }); 
} 

functionbarLeft(i) { 
    return i * (30 + 2); 
} 

functionbarHeight(d) { 
    returnd.value; 
} 

setInterval(function () { 
               data.shift(); 
               push(data); 
               render(data); 
    }, 2000); 

render(data); 

d3.select("body") 
       .append("div") 
           .attr("class", "baseline") 
           .style("position", "fixed") 
           .style("z-index", "1") 
           .style("top", chartHeight + "px") 
           .style("left", "0px") 
           .style("width", chartWidth + "px"); 
</script> 

以下代码将在您的网页浏览器中生成一个滑动条形图,如下面的截图所示:

如何实现...

滑动条形图

它是如何工作的...

表面上,这个例子似乎相当复杂,效果复杂。每秒钟都需要创建一个新的条形并动画化,而其余的条形需要精确滑动。D3 集合导向功能 API 的美丽之处在于,无论你操作多少元素,它的工作方式都是完全相同的;因此,一旦你理解了机制,你就会意识到这个配方与之前的配方并没有太大的不同。

作为第一步,我们在第 A 行创建了一系列垂直条形的数据绑定选择,然后可以在经典的 enter-update-exit 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}。对象恒定性在这个过程中至关重要;没有对象恒定性,滑动效果将无法实现。

注意

如果你想了解更多关于对象恒定性的信息,请查看 D3 的创造者 Mike Bostock 的这篇优秀的文章:bost.ocks.org/mike/constancy/

第二步是使用 d3.selection.enter 函数创建这些垂直条形,并根据索引号计算每个条形的 left 位置(参考第 B 行):

// enter 
selection.enter() 
                .append("div") 
                .attr("class", "v-bar") 
                .style("z-index", "0") 
                .style("position", "fixed") 
                .style("top", chartHeight + "px") 
                .style("left", function(d, i){ 
returnbarLeft(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) {  
returnchartHeight - barHeight(d) + "px";  
                }) 
                .style("left", function(d, i){ 
returnbarLeft(i) + "px"; 
                }) 
                .style("height", function (d) {  
returnbarHeight(d) + "px";  
                }) 
                .select("span") 
                    .text(function (d) {return d.value;}); 

完成进入部分后,我们现在可以处理 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){ 
returnbarLeft(-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-v2/blob/master/src/chapter6/easing.html

如何操作...

在以下代码示例中,我们将演示如何按元素逐个自定义转换缓动:

<script type="text/javascript"> 
var data = [ // <-A 
            {name: 'Linear', fn: d3.easeLinear}, 
            {name: 'Cubic', fn: d3.easeCubic}, 
            {name: 'CubicIn', fn: d3.easeCubicIn}, 
            {name: 'Sin', fn: d3.easeSin}, 
            {name: 'SinIn', fn: d3.easeSinIn}, 
            {name: 'Exp', fn: d3.easeExp}, 
            {name: 'Circle', fn: d3.easeCircle}, 
            {name: 'Back', fn: d3.easeBack}, 
            {name: 'Bounce', fn: d3.easeBounce}, 
            {name: 'Elastic', fn: d3.easeElastic}, 
            {name: 'Custom', fn: function(t){ return t * t; }}// <-B 
    ], 
colors = d3.scaleOrdinal(d3.schemeCategory20); 

d3.select("body").selectAll("div") 
            .data(data) // <-C 
        .enter() 
        .append("div") 
            .attr("class", "fixed-cell") 
            .style("top", function (d, i) { 
            returni * 40 + "px"; 
            }) 
            .style("background-color", function (d, i) { 
            return colors(i); 
            }) 
            .style("color", "white") 
            .style("left", "500px") 
            .text(function (d) { 
            return d.name; 
            }); 

d3.selectAll("div").each(function(d){ 
d3.select(this) 
      .transition().ease(d.fn) // <-D 
      .duration(1500) 
      .style("left", "10px"); 
    }); 
</script> 

上述代码生成了一组具有不同缓动效果的移动框。以下截图是在缓动效果发生时捕获的:

如何操作...

不同的缓动效果

它是如何工作的...

在这个菜谱中,我们展示了多个不同的内置 D3 缓动函数及其对过渡的影响。让我们看看它是如何完成的;首先,我们创建了一个数组来存储我们想要展示的不同缓动模式:

var data = [ // <-A 
            {name: 'Linear', fn: d3.easeLinear}, 
            {name: 'Cubic', fn: d3.easeCubic}, 
            {name: 'CubicIn', fn: d3.easeCubicIn}, 
            {name: 'Sin', fn: d3.easeSin}, 
            {name: 'SinIn', fn: d3.easeSinIn}, 
            {name: 'Exp', fn: d3.easeExp}, 
            {name: 'Circle', fn: d3.easeCircle}, 
            {name: 'Back', fn: d3.easeBack}, 
            {name: 'Bounce', fn: d3.easeBounce}, 
            {name: 'Elastic', fn: d3.easeElastic}, 
            {name: 'Custom', fn: function(t){ return t * t; }}// <-B 
    ], 
colors = d3.scaleOrdinal(d3.schemeCategory20); 

虽然所有内置的缓动函数都简单地使用它们的名称定义,但此数组的最后一个元素是一个自定义缓动函数(二次缓动)。然后,之后,使用此数据数组创建了一组 div 元素,并为每个 div 元素创建了一个具有不同缓动函数的过渡,将它们从 ("left", "500px") 移动到 ("left", "10px"),如下所示:

d3.selectAll("div").each(function(d){ 
d3.select(this) 
      .transition().ease(d.fn) // <-D 
      .duration(1500) 
      .style("left", "10px"); 
    }); 

到目前为止,你可能想知道,为什么我们没有像通常为任何其他 D3 属性所做的那样,直接使用函数指定缓动?

d3.selectAll("div").transition().ease(d.fn) // does not work 
        .duration(1500) 
        .style("left", "10px"); 

原因是它不适用于 ease() 函数。我们在第 D 行展示的是这个限制的解决方案;尽管在实际项目中,你很少需要针对每个元素自定义缓动行为。

注意

另一种绕过这种限制的方法是使用自定义缓动,我们将在下一个菜谱中介绍。

如第 D 行所示,为 D3 过渡指定不同的缓动函数非常简单;你所需要做的就是在一个过渡绑定的选择上调用 ease() 函数。D3 还提供了缓动模式修饰符,你可以将它们与任何缓动函数结合使用以实现额外的效果,例如 sin-out 或 quad-out-in。以下是可以用的缓动模式修饰符:

  • In: 默认

  • Out: 反转

  • InOut: 反射

  • OutIn: 反转和反射

注意

D3 使用的默认缓动效果是 easeCubic()。有关支持的 D3 缓动函数列表,请参阅以下链接:github.com/d3/d3-ease 对于想要可视化探索不同内置缓动模式的任何人,可以查看由 D3 的创建者构建的此可视化缓动探索器:bl.ocks.org/mbostock/248bac3b8e354a9103c4

当使用自定义缓动函数时,该函数应接受当前参数时间值作为其参数,范围在 [0, 1] 之间,如下面的函数所示。

function(t){ // <-B 
    return t * t; 
} 

在我们的例子中,我们实现了一个简单的二次缓动函数,这实际上是一个内置的 D3 缓动函数,名为 quad。

注意

关于缓动和 Penner 方程(包括 D3 和 jQuery 在内的大多数现代 JavaScript 框架实现)的更多信息,请查看以下链接:www.robertpenner.com/easing/

使用缓动

缓动一词来自“inbetween”,这是在传统动画中的一种常见做法,在关键帧由主动画师创建后,经验较少的动画师用于在关键帧之间生成帧。这个短语被借用到现代计算机生成的动画中,指的是控制如何生成inbetween帧的技术或算法。在这个菜谱中,我们将检查 D3 过渡如何支持缓动。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter6/tweening.html

如何做...

在以下代码示例中,我们将创建一个自定义缓动函数来通过九个离散整数来动画化按钮标签:

<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(d3.easeLinear) 
            .style("width", "400px") 
            .attr("value", "9"); 

body.append("div").append("input") 
        .attr("type", "button") 
        .attr("class", "countdown") 
        .attr("value", "0") 
        .transition().duration(duration).ease(d3.easeLinear) 
.styleTween("width", widthTween) // <- A 
            .attrTween("value", valueTween); // <- B 

functionwidthTween(a){ 
var interpolate = d3.scaleQuantize() 
            .domain([0, 1]) 
            .range([150, 200, 250, 350, 400]); 

              return function(t){ 
              return interpolate(t) + "px"; 
        }; 
    } 

functionvalueTween(){ 
var interpolate = d3.scaleQuantize() // <-C 
            .domain([0, 1]) 
            .range([1, 2, 3, 4, 5, 6, 7, 8, 9]); 

             return function(t){ // <-D 
             return interpolate(t); 
        }; 
    }         
</script> 

上述代码生成两个以非常不同的速率变形的按钮,以下截图是在此过程进行时拍摄的:

如何做...

缓动

它是如何工作的...

在这个菜谱中,第一个按钮是通过简单的过渡和线性缓动创建的:

body.append("div").append("input") 
        .attr("type", "button") 
        .attr("class", "countdown") 
        .attr("value", "0") 
        .style("width", "150px") 
        .transition().duration(duration).ease(d3.easeLinear) 
            .style("width", "400px") 
            .attr("value", "9"); 

过渡将按钮的宽度从150px变为400px,同时将其值从0变为9。正如预期的那样,这个过渡简单地依赖于使用 D3 字符串插值器对这些值的连续线性插值。相比之下,第二个按钮的效果是分块改变这些值,从 1 变为 2,然后到 3,依此类推,直到 9。这是通过使用 D3 缓动支持中的attrTweenstyleTween函数实现的。让我们首先看看按钮值缓动是如何工作的:

  .transition().duration(duration).ease(d3.easeLinear) 
            .styleTween("width", widthTween) // <- A 
            .attrTween("value", valueTween); // <- B 

在前面的代码片段中,我们可以看到,与我们在第一个按钮的情况下设置值属性的结束值不同,我们使用了attrTween函数并提供了一对缓动函数widthTweenvalueTween,它们如下实现:

functionwidthTween(a){ 
var interpolate = d3.scaleQuantize() 
            .domain([0, 1]) 
            .range([150, 200, 250, 350, 400]); 

return function(t){ 
return interpolate(t) + "px"; 
        }; 
    } 

functionvalueTween(){ 
var interpolate = d3.scaleQuantize() // <-C 
            .domain([0, 1]) 
            .range([1, 2, 3, 4, 5, 6, 7, 8, 9]); 

return function(t){ // <-D 
return interpolate(t); 
        }; 
    } 

在 D3 中,一个缓动函数预期是一个工厂函数,它构建将被用于执行缓动的实际函数。在这种情况下,我们在行C上定义了一个将域[0, 1]映射到离散整数范围[1, 9]quantize刻度。在行D上定义的实际缓动函数简单地使用量化刻度插值参数时间值,从而产生跳跃整数效果。

注意

量化刻度是线性刻度的变体,它具有离散的范围而不是连续的范围。有关量化刻度的更多信息,请访问以下链接:github.com/d3/d3/blob/master/API.md#quantize-scales

更多内容...

到目前为止,我们已经触及了与过渡相关的三个概念:缓动、缓动和插值。通常,D3 过渡通过以下序列图中的三个级别定义和驱动:

还有更多...

`

过渡的驱动程序

如我们通过多个示例所示,D3 过渡支持在三个级别上进行自定义。这为我们提供了极大的灵活性,可以精确地按照我们的意愿自定义过渡行为。

注意

虽然自定义缓动通常使用插值实现,但你在自己的缓动函数中可以做到的事情没有限制。完全有可能在不使用 D3 插值器的情况下生成自定义缓动。

在这个示例中,我们使用了线性缓动来突出缓动效果;然而,D3 完全支持缓动缓动,这意味着你可以将之前示例中展示的任何缓动函数与你的自定义缓动函数结合,以生成更复杂的过渡效果。

使用过渡链式调用

本章的前四个示例专注于 D3 中的单个过渡控制,包括自定义缓动和缓动函数。然而,有时,无论你进行多少缓动或缓动,单个过渡都远远不够;例如,你可能想要通过首先将 div 元素挤压成一条光束,然后将光束传递到网页上的不同位置,最后将 div 恢复到原始大小来模拟传送 div 元素。在这个示例中,我们将看到如何使用过渡链式调用实现这种类型的过渡。

准备工作

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

raw.githubusercontent.com/NickQiZhu/d3-cookbook-v2/master/src/chapter6/chaining.html

如何实现...

我们简单的传送过渡代码出奇地短:

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

function teleport(s){ 
s.transition().duration(1000) // <-A 
            .style("width", "200px") 
            .style("height", "1px") 
        .transition().duration(500) // <-B 
            .style("left", "600px") 
        .transition().duration(1000) // <-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(1000) // <-A 
        .style("width", "200px") 
        .style("height", "1px") 
    .transition().duration(500) // <-B 
        .style("left", "600px") 
    .transition().duration(1000) // <-C 
        .style("left", "800px") 
        .style("height", "80px") 
        .style("width", "80px"); 
}; 

第一个过渡在行 A(压缩)上定义并启动;然后,在行 B 上创建第二个过渡(发光);最后,第三个过渡在行 C(恢复)上链式调用。通过将简单的过渡拼接在一起,过渡链式调用是一种强大而简单的技术,可以编排复杂的过渡效果。最后,在这个示例中,我们还通过将传送过渡包装在函数中,然后使用 d3.selection.call 函数在选择上应用它,展示了如何实现一个基本的可重用组合过渡效果(参见图 D)。可重用过渡效果对于遵循 DRY 原则至关重要,尤其是在你的可视化动画变得更加复杂时。

使用过渡过滤器

在某些情况下,你可能需要选择性地将过渡应用到某个选择集的子集。在这个菜谱中,我们将使用数据驱动的过渡过滤技术来探索这种效果。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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> 

过渡后页面看起来是这样的:

如何做到这一点...

过渡过滤

它是如何工作的...

菜谱的初始设置非常简单,因为我们希望尽可能减少管道的复杂性,以便帮助你专注于技术的核心。我们有一个包含交错字符串CatDog的数据数组。然后,为数据创建了一组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-v2/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 
                .on("start", function(){ // <-D 
                d3.select(this).text(function (d, i) { 
                return "transitioning"; 
                    }); 
                }) 
                .on("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 上,然而,我们也为过渡指定了延迟,因此在过渡开始之前显示了 等待 标签。接下来,让我们找出我们是如何在过渡期间显示 过渡中 标签的:

.on("start", function(){ // <-D 
d3.select(this).text(function (d, i) { 
  return "transitioning"; 
    }); 
}) 

这是通过调用 on() 函数并选择将其第一个参数设置为 "start" 事件名称,并将事件监听器函数作为第二个参数传递来实现的。事件监听器函数的 this 引用指向当前选定的元素,因此可以被 D3 包装并进行进一步操作。过渡 "end" 事件以相同的方式处理:

.on("end", function(){ // <-E 
d3.select(this).text(function (d, i) { 
  return "done"; 
    }); 
}) 

这里的唯一区别是事件名称被传递到 on() 函数中。

使用计时器

到目前为止,在本章中我们已经讨论了关于 D3 过渡的各种主题。此时,你可能会有这样的疑问,是什么在驱动 D3 过渡,从而生成动画帧?

在本菜谱中,我们将探索一个低级别的 D3 计时器函数,你可以利用它从头开始创建自己的自定义动画。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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"); 

functioncountUp(target){ // <-A 
  var t = d3.timer(function(){ // <-B 
  var value = countdown.attr("value"); 
    if( value == target ) { 
       t.stop(); 
          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):

functioncountUp(target){ // <-A 
    var t = d3.timer(function(){ // <-B 
        var value = countdown.attr("value"); 
        if( value == target ) { 
            t.stop(); 
            return true; 
        }  // <-C 
        countdown.attr("value", ++value); // <-D             
    }); 
} 

正如我们在本例中所示,理解这个菜谱的关键在于 d3.timer 函数。这个 d3.timer(function, [delay], [mark]) 开始一个自定义计时器函数,并重复调用给定的函数,直到函数返回 true 或计时器停止。在 D3 v4 之前,一旦计时器开始,就没有办法停止它,因此程序员必须确保函数最终返回 true;在最新的 D3 版本中,计时器对象现在提供了一个显式的 stop() 函数。然而,仍然建议一旦计时器完成了它的任务,从计时器函数中返回 true,就像在图 C 中看到的那样。可选地,你也可以指定一个 延迟 或一个 标记。延迟从标记开始,如果没有指定标记,则使用 Date.now 作为标记。以下插图显示了我们所讨论的时间关系:

在我们的实现中,自定义的 timer 函数每次被调用时都会将按钮标题增加一(参见图 D),并在值达到 100 时停止(参见图 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 与其他 Web 技术(如 CSS 和 JavaScript)配合良好;D3 本身就是这种能力的完美展示。

  • 轻量级:与基于位图的图像相比,SVG 要轻得多,占用的空间也小得多。

由于我们之前提到的所有这些功能,SVG 已经成为 Web 上数据可视化的事实标准。从本章开始,本书中的所有食谱都将使用 SVG 作为其最重要的部分进行展示,通过 SVG 可以充分发挥 D3 的真正威力。

注意

一些较旧的浏览器不支持 SVG。如果您的目标用户正在使用旧版浏览器,请在决定 SVG 是否是您可视化项目的正确选择之前检查 SVG 兼容性。您可以访问以下链接来检查您浏览器的兼容性:caniuse.com/#feat=svg

创建简单形状

在本食谱中,我们将探索一些简单的内置 SVG 形状公式及其属性。这些简单的形状很容易生成,通常在需要时手动使用 D3 创建。尽管这些简单形状不是与 D3 一起工作时最有用的形状生成器,但偶尔在可视化项目的边缘形状绘制时可能会很有用。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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 形状都可以使用样式属性直接或通过类似于 HTML 元素的 CSS 进行样式化。此外,它们可以使用 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 属性,它由以下命令组成:

  • 移动到: M (绝对)/m (相对) 移动到 (x y)+

  • 闭合路径: Z (绝对)/z (相对) 闭合路径

  • 直线到: 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.scaleLinear() // <-A 
            .domain([0, 10]) 
            .range([margin, width - margin]), 
        y = d3.scaleLinear() // <-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.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.scaleLinear() // <-A .domain([0, 10]) .range([margin, width - margin]), y = d3.scaleLinear() // <-B .domain([0, 10]) .range([height - margin, margin]);

注意,这些比例尺的域被设置为足够大,以包含两个系列中的所有数据点,而范围被设置为表示画布区域而不包括边距。由于我们希望原点位于画布的左下角而不是 SVG 标准的左上角,因此y轴的范围是反转的。一旦数据和比例尺都设置好了,我们只需要生成线,使用d3.line函数定义我们的生成器:

var line = d3.line() // <-D 
            .x(function(d){return x(d.x);}) 
            .y(function(d){return y(d.y);}); 

d3.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)。然后,使用我们之前创建的线生成器通过传递数据d作为其输入参数来设置每个路径元素的d属性。以下截图显示了生成的svg:path元素的外观:

如何工作...

生成的 SVG 路径元素

最后,使用我们之前定义的相同xy比例创建了两个轴。由于本书空间有限,我们在本食谱和本章的其余部分省略了与轴相关的代码,因为它们实际上并没有改变,也不是本章的重点。

参见

有关 D3 轴支持详细信息的说明,请参阅第五章 Chapter 5. 玩转轴,玩转轴

使用线曲线

默认情况下,D3 线生成器使用线性曲线模式;然而,D3 支持多种不同的曲线工厂。曲线函数决定了数据点将以何种方式连接,例如,通过直线(线性)或曲线(B 样条)。在本食谱中,我们将向您展示如何设置这些曲线模式及其效果。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter7/line-curve.html

本食谱是在前一个食谱的基础上构建的,所以如果您不是

如果您还不熟悉基本的线生成器函数,请首先参考前面的食谱

然后再继续。

如何做...

现在,让我们看看如何使用不同的线插值模式:

<script type="text/javascript"> 
var width = 500, 
        height = 500, 
        margin = 30, 
        x = d3.scaleLinear() 
            .domain([0, 10]) 
            .range([margin, width - margin]), 
        y = d3.scaleLinear() 
            .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(d3.curveLinear); 

    renderDots(svg); 

    function render(mode){ 
        var line = d3.line() 
                .x(function(d){return x(d.x);}) 
                .y(function(d){return y(d.y);}) 
                .curve(mode); // <-A 

        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(list){ 
             svg.append("g").selectAll("circle") 
                .data(list) 
              .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 
... 
</script> 

<h4>Interpolation Mode:</h4> 
<div class="control-group"> 
<button onclick="render(d3.curveLinear)">linear</button> 
<button onclick="render(d3.curveLinearClosed)">linear closed</button> 
<button onclick="render(d3.curveStepBefore)">step before</button> 
<button onclick="render(d3.curveStepAfter)">step after</button> 
<button onclick="render(d3.curveBasis)">basis</button> 
<button onclick="render(d3.curveBasisOpen)">basis open</button> 
</div> 
... 

以下代码生成浏览器中的以下折线图,具有可配置的插值模式:

如何做...

线性曲线

它是如何工作的...

总体而言,本食谱与上一个食谱类似。使用预定义的数据集生成两条线。然而,在本食谱中,您将允许用户选择特定的线插值模式,然后使用以下代码片段中的线生成器上的interpolate函数(参考行A)来设置:

var line = d3.line() 
                .x(function(d){return x(d.x);}) 
                .y(function(d){return y(d.y);}) 
                .curve(mode); // <-A 

D3 支持以下插值模式:

  • d3.curveLinear: 线性段,即折线

  • d3.curveLinearClosed: 闭合线性段,即多边形

  • d3.curveStepBefore: 在垂直和水平段之间交替,就像步函数一样

  • d3.curveStepAfter: 在水平和垂直段之间交替,就像步函数一样

  • d3.curveBasis: 它是一个 B 样条,两端有控制点重复

  • d3.curveBasisOpen: 开放 B 样条;可能不会与起点或终点相交

  • d3.curveBasisClosed: 闭合的 B 样条,就像一个环

  • d3.curveBundle: 等同于基函数,但张力参数用于使样条变直

  • d3.curveCardinal: 一种基数样条,两端有控制点重复。

  • d3.curveCardinalOpen: 开放基数样条;可能不会与起点或终点相交,但会与其他控制点相交

  • d3.curveCardinalClosed: 闭合基数样条,就像一个环

  • d3.curveMonotoneY: 保留 y 的单调性的立方插值

  • d3.curveCatmullRom: 立方 catmull-Rom 样条。

此外,在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-v2/blob/master/src/chapter7/line-tension.html

如何做...

现在,让我们看看如何改变线条张力以及它对线条生成的影响:

<script type="text/javascript"> 
    var width = 500, 
        height = 500, 
        margin = 30, 
        duration = 500,     
        x = d3.scaleLinear() 
            .domain([0, 10]) 
            .range([margin, width - margin]), 
        y = d3.scaleLinear() 
            .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.line() 
                .curve(d3.curveCardinal.tension(tension)) // <-A 
                .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])  
            .transition().duration(duration) 
               .ease(d3.easeLinear) // <-B 
            .attr("d", function(d){ 
                return line(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):

var line = d3.line() 
                .curve(d3.curveCardinal.tension(tension)) // <-A  
                .x(function(d){return x(d.x);}) 
                .y(function(d){return y(d.y);}); 

此外,我们还在图线B上启动了一个过渡,以突出张力对线条插值的影响。基数曲线的张力本质上决定了切线的长度。在张力为 1 时,它与曲线线性相同,而在张力为 0 时,它产生均匀的 Catmull-Rom 样条。如果没有明确设置张力,基数插值默认将张力设置为0

使用区域生成器

使用 D3 线生成器,我们可以技术上生成任何形状的轮廓;然而,即使有不同的曲线支持,直接使用线(如面积图)绘制面积也不是一件容易的事情。这就是为什么 D3 还提供了一个专门为绘制面积而设计的独立形状生成器函数。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter7/area.html

如何做...

在这个菜谱中,我们将向伪线图添加填充面积,从而有效地将其转换为

面积图:

<script type="text/javascript"> 
    var width = 500, 
        height = 500, 
        margin = 30, 
        duration = 500, 
        x = d3.scaleLinear() // <-A 
            .domain([0, 10]) 
            .range([margin, width - margin]), 
        y = d3.scaleLinear() 
            .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();     

    renderDots(svg); 

    function render(){ 
        var line = d3.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.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.scaleLinear() // <-A 
            .domain([0, 10]) 
            .range([margin, width - margin]), 
        y = d3.scaleLinear() 
            .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.area 函数创建面积生成器(参见图 C):

var area = d3.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 面积生成器用于生成 svg:path 元素上的 d 公式(参见图 H),其中数据 d 作为其输入参数。

使用面积曲线

与 D3 线生成器类似,面积生成器也支持相同的插值模式,因此它可以与线生成器在每种模式下结合使用。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter7/area-curve.html

如何做...

在这个菜谱中,我们将展示如何配置面积生成器的插值模式。这样,就可以创建与其对应的线匹配的插值面积:

var width = 500, 
        height = 500, 
        margin = 30, 
        x = d3.scaleLinear() 
            .domain([0, 10]) 
            .range([margin, width - margin]), 
        y = d3.scaleLinear() 
            .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(d3.curveLinear); 

    renderDots(svg); 

    function render(mode){ 
        var line = d3.line() 
                .x(function(d){return x(d.x);}) 
                .y(function(d){return y(d.y);}) 
                .curve(mode); // <-A 

        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.area() 
            .x(function(d) { return x(d.x); }) 
            .y0(y(0)) 
            .y1(function(d) { return y(d.y); }) 
            .curve(mode); // <-B 

        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.line() 
                .x(function(d){return x(d.x);}) 
                .y(function(d){return y(d.y);}) 
                .curve(mode); // <-A 

var area = d3.area() 
            .x(function(d) { return x(d.x); }) 
            .y0(y(0)) 
            .y1(function(d) { return y(d.y); }) 
            .curve(mode); // <-B 

如您所见,曲线模式已在两条线上通过 curve 函数配置,同时与面积生成器一起(参见图 A 和 B)。由于 D3 线和面积生成器支持相同的曲线工厂集合,因此它们可以始终生成与本食谱中看到的匹配的线和面积。

还有更多...

D3 面积生成器在采用 Cardinal 模式时也支持相同的张力配置;然而,由于它与线生成器的张力支持相同,并且由于本书篇幅有限,我们在此不涵盖面积张力。

参见

使用弧生成器

在最常见的形状生成器中——除了线和面积生成器之外——D3 还提供了 弧生成器。此时,你可能想知道,SVG 标准已经包含了圆形元素,这还不够吗?

对此的简单答案是 。D3 弧生成器比简单的 svg:circle 元素要灵活得多。D3 弧生成器不仅能创建圆,还能创建环形(甜甜圈)、圆形扇区和环形扇区,所有这些我们将在本食谱中学习。更重要的是,弧生成器旨在生成弧(换句话说,不是完整的圆或扇区,而是任意角度的弧)。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter7/arc.html

如何操作...

在本食谱中,我们将使用弧生成器生成多切片圆、环形(甜甜圈)、圆形扇区和环形扇区,如下所示:

<script type="text/javascript"> 
    var width = 400, 
        height = 400, 
        fullAngle = 2 * Math.PI, // <-A 
        colors =  d3.scaleOrdinal(d3.schemeCategory20); 

    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.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} 
]; 

弧数据集中的每一行都必须包含两个必填字段:startAngleendAngle。角度必须在 [0, 2 * Math.PI] 范围内(参见图 A)。D3 弧生成器将使用这些角度生成相应的切片,如本食谱中前面所示。

注意

除了起始角和结束角,弧数据集还可以包含任意数量的附加字段,然后可以在 D3 函数中访问这些字段以驱动其他视觉表示。

如果你认为根据你拥有的数据计算这些角度将会非常麻烦,你完全正确。这就是为什么 D3 提供了一个特定的布局管理器来帮助你计算这些角度,我们将在下一章中介绍这一点。现在,让我们专注于理解幕后基本机制,这样当需要介绍布局管理器或者你任何时候需要手动设置角度时,你将能够充分准备。

var arc = d3.arc().outerRadius(200) // <-C 
                    .innerRadius(innerRadius);  

d3.arc函数可选地具有outerRadiusinnerRadius设置。当设置innerRadius时,弧生成器将生成一个环面(甜甜圈)的图像而不是一个圆。最后,D3 弧也是使用svg:path元素实现的,因此与线和面积生成器类似,d3.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-v2/blob/master/src/chapter7/arc-transition.html

如何做...

在这个菜谱中,我们将动画一个多切片环,每个切片从角度0开始过渡到其最终所需的角度,最终形成一个完整的环。

<script type="text/javascript"> 
    var width = 400, 
            height = 400, 
            endAngle = 2 * Math.PI, 
            colors = d3.scaleOrdinal(d3.schemeCategory20c); 

    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.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) { 
                  var start = {startAngle: 0, endAngle: 0}; // <-A 
                  var interpolate = d3.interpolate(start, d); // <-B 
                  return function (t) { 
                      return arc(interpolate(t)); // <-C 
                  }; 
                }); 
    } 

    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) 
            .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 社区中普遍接受的图表约定;否则,您可能会创建出可能会让用户感到困惑而不是帮助他们的图表库。

注意

如您所想象,D3 图表通常使用 SVG 而不是 HTML 来实现;然而,我们在这里将要讨论的约定也适用于基于 HTML 的图表,尽管实现细节将有所不同。

让我们先看看以下图表:

D3 图表约定

D3 图表约定

如此图表所示,SVG 图像中的原点 (0, 0) 位于其最左上角,正如预期的那样;然而,这个约定的最重要方面是关于如何定义图表边距,以及轴线的位置。

  • 边距:首先,让我们看看这个约定最重要的方面——边距。正如您所看到的,对于每个图表,都有四种不同的边距设置:左边距、右边距、上边距和下边距。灵活的图表实现应该允许用户为这些边距中的每一个设置不同的值,我们将在后面的食谱中看到如何实现这一点。

  • 坐标转换:其次,这种约定还建议使用 SVG 平移变换来定义图表主体(灰色区域)的坐标参考,translate(margin.left, margin.top)。这种平移有效地将图表主体区域移动到所需的位置;并且这种方法的另一个额外好处是,通过改变图表主体坐标的参考框架,简化了在图表主体内部创建子元素的工作,因为边距大小变得无关紧要。对于图表主体内部的任何子元素,其原点 (0, 0) 现在是图表主体区域的左上角。

  • :最后,此约定的最后一个方面是关于图表轴如何以及在哪里放置。如图所示,图表轴放置在图表边距内部,而不是作为图表主体的一部分。这种方法的优势在于将轴视为图表中的外围元素,因此不会使图表主体实现复杂化,并且还使轴渲染逻辑与图表独立且易于重用。

现在,让我们利用迄今为止所学的所有知识和技巧,创建我们的第一个可重用 D3 图表。

小贴士

要了解 D3 创建者的解释,请访问bl.ocks.org/mbostock/3019563

创建折线图

折线图是一种常见的基本图表类型,在许多领域得到广泛应用。此图表由一系列通过直线段连接的数据点组成。折线图通常由两条垂直轴包围:x 轴和 y 轴。在此食谱中,我们将探讨如何使用 D3 实现这种基本图表,作为一个可配置为在不同尺度上显示多个数据系列的可重用 JavaScript 对象。除此之外,我们还将展示实现具有动画的动态多数据系列更新的技术。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter8/line-chart.html

阅读此食谱时,强烈建议您打开配套的代码示例。

如何操作...

让我们看一下以下实现此图表类型的代码;由于食谱的长度,我们在这里只展示代码的概要,而详细内容将在工作原理...部分中介绍:

<script type="text/javascript"> 
// First we define the chart object using a functional object 

function lineChart() { // <-1A 
    ... 
    // main render function  
    _chart.render = function () { // <-2A 
    ... 
    }; 

    // axes rendering function 
    functionrenderAxes(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.scaleOrdinal(d3.schemeCategory10), 
            _svg, 
            _bodyG, 
            _line; 

      ... 

      _chart.width = function (w) { 
            if (!arguments.length) return _width; 
            _width = w; 
            return _chart; 
        }; 

        _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.scaleLinear().domain([0, 10])) 
            .y(d3.scaleLinear().domain([0, 10])); 

data.forEach(function (series) { 
    chart.addSeries(series); 
}); 

chart.render(); 

如您所见,图表对象是在第 1A 行使用名为lineChart的函数定义的,遵循我们在第一章中讨论的理解 D3 风格的 JavaScript配方中的功能对象模式,使用 D3.js 入门。利用功能对象模式提供的信息隐藏的更大灵活性,我们定义了一系列内部属性,所有属性名都以下划线开头(第 1B 行)。其中一些属性通过提供访问器函数(第 1C 行)公开。公开可访问的属性如下:

  • width:图表 SVG 总宽度(以像素为单位)

  • height:图表 SVG 总高度(以像素为单位)

  • margins:图表边距

  • colors:用于区分不同数据系列的图表序数颜色刻度

  • xx轴刻度

  • yy轴刻度

访问器函数是使用我们在第一章中介绍的技术实现的,使用 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 
    varaxesG = svg.append("g") 
                   .attr("class", "axes"); 

    renderXAxis(axesG); 

    renderYAxis(axesG); 
} 

如前一章所述,x轴和y轴都在图表边距区域内渲染。我们不会详细介绍轴的渲染,因为我们已经在第五章中详细讨论了这一主题,玩转坐标轴

渲染数据系列

到目前为止,我们在这个食谱中讨论的内容并不仅限于这种图表类型,而是一个与其他基于笛卡尔坐标的图表类型共享的框架。最后,现在我们可以讨论这个食谱的核心——如何为多个数据系列创建线段和点。让我们看看以下负责数据系列渲染的代码片段:

function renderLines() { 
        _line = d3.line() //<-4A 
                        .x(function (d) { return _x(d.x); }) 
                        .y(function (d) { return _y(d.y); }); 

         var pathLines = _bodyG.selectAll("path.line") 
                    .data(_data); 

        pathLines 
                .enter() //<-4B 
                    .append("path") 
                .merge(pathLines) 
                    .style("stroke", function (d, i) { 
                        return _colors(i); //<-4C 
                    }) 
                    .attr("class", "line") 
                .transition() //<-4D 
                    .attr("d", function (d) {  
                                return _line(d);  
                    }); 
} 

function renderDots() { 
    _data.forEach(function (list, i) { 
        var circle = _bodyG.selectAll("circle._" + i) //<-4E 
                .data(list); 

        circle.enter() 
                .append("circle") 
            .merge(circle) 
                .attr("class", "dot _" + i) 
                .style("stroke", function (d) { 
                    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.line生成器在第 4A 行创建,用于创建映射数据系列的svg:path。使用 Enter-and-Update 模式在第 4B 行创建数据线。第 4C 行根据索引为每条数据线设置不同的颜色。最后,第 4E 行在更新模式下设置过渡,使数据线在每次更新时都能平滑移动。renderDots函数执行类似的渲染逻辑,生成一组代表每个数据点的svg:circle元素(第 4E 行),根据数据系列索引(第 4F 行)协调其颜色,并在第 4G 行上启动过渡,这样点就可以在数据更新时与线一起移动。

如本食谱所示,创建一个可重用的图表组件实际上需要做很多工作。然而,超过三分之二的代码用于创建外围图形元素和访问器方法。因此,在实际项目中,你可以提取这部分逻辑,并将此实现的大部分用于其他图表;尽管我们没有在我们的食谱中这样做以降低复杂性,但你仍然可以快速掌握图表渲染的所有方面。由于本书的范围有限,在后面的食谱中,我们将省略所有外围渲染逻辑,仅关注与每种图表类型相关的核心逻辑。如果你在阅读本章后面的食谱时需要再次检查外围渲染逻辑,请随时回到这个食谱。

创建面积图

面积图或面积图与折线图非常相似,主要基于折线图实现。面积图与折线图的主要区别在于,在面积图中,轴和线之间的区域将被填充颜色或纹理。在本食谱中,我们将探讨实现一种称为分层面积图的面积图的技术。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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.line() 
                        .x(function (d) { return _x(d.x); }) 
                        .y(function (d) { return _y(d.y); }); 

        var pathLines = _bodyG.selectAll("path.line") 
                .data(_data); 

        pathLines.enter() 
                    .append("path") 
                .merge(pathLines) 
                    .style("stroke", function (d, i) { 
                        return _colors(i); 
                    }) 
                    .attr("class", "line") 
                .transition() 
                    .attr("d", function (d) { return _line(d); }); 
    } 

    function renderDots() { 
        _data.forEach(function (list, i) { 
            var circle = _bodyG.selectAll("circle._" + i) 
                    .data(list); 

            circle.enter() 
                    .append("circle") 
                .merge(circle) 
                    .attr("class", "dot _" + i) 
                    .style("stroke", function (d) { 
                        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.area() // <-A 
                    .x(function(d) { return _x(d.x); }) 
                    .y0(yStart()) 
                    .y1(function(d) { return _y(d.y); }); 

        var pathAreas = _bodyG.selectAll("path.area") 
                .data(_data); 

        pathAreas.enter() // <-B 
                .append("path") 
            .merge(pathAreas) 
                .style("fill", function (d, i) {  
                    return _colors(i);  
                }) 
                .attr("class", "area") 
            .transition() // <-D 
                .attr("d", function (d) {  
                    return area(d); // <-E 
                }); 
    } 
... 

这个菜谱生成了以下层次面积图:

如何做...

层次面积图

工作原理...

正如我们之前提到的,由于面积图的实现基于我们的折线图实现,实现的大部分内容与折线图相同。实际上,面积图需要渲染折线图中实现的精确线条和点。关键的区别在于renderAreas函数。在这个菜谱中,我们依赖于第七章中讨论的面积生成技术,即“形状入门”。在行A上创建了d3.area生成器,其上边线与线条匹配,而下边线(y0)固定在 x 轴上。

var area = d3.area() // <-A 
  .x(function(d) { return _x(d.x); }) 
  .y0(yStart()) 
  .y1(function(d) { return _y(d.y); }); 

一旦定义了面积生成器,就采用经典的“进入-更新”模式来创建和更新面积。在进入情况下(行B),为每个数据系列创建了一个svg:path元素。在行B2中,我们将pathAreas.enter()pathAreas合并;因此,所有后续代码都将应用于进入和更新模式;所有面积都使用其系列索引进行着色,因此将与我们的线和点匹配颜色(行C):

Var pathAreas = _bodyG.selectAll("path.area") 
                .data(_data); 

pathAreas.enter() // <-B 
.append("path") 
.merge(pathAreas) // <-B2 
.style("fill", function (d, i) {  
    return _colors(i); // <-C 
  }) 
  .attr("class", "area") 
.transition() // <-D 
  .attr("d", function (d) {  
       return area(d); // <-E 
  }); 

当数据更新时,以及对于新创建的面积,我们开始一个过渡(行 D)来更新面积svg:path元素的d属性到所需的形状(行 E)。由于我们知道折线图实现更新时同时动画化线和点,我们这里的面积更新过渡有效地允许面积根据图表中的线和点进行动画化和移动。

最后,我们还添加了path.area的 CSS 样式以降低其不透明度,使区域变得透明,从而实现我们想要的分层效果

.area { 
  stroke: none; 
  fill-opacity: .2; 
} 

创建散点图图表

散点图或散点图是另一种常见的图表类型,用于在笛卡尔坐标系上显示具有两个不同变量的数据点。散点图在探索扩散、聚类和分类问题时特别有用。在这个菜谱中,你将学习如何在 D3 中实现多系列散点图图表。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter8/scatterplot-chart.html

如何做...

散点图是另一种使用笛卡尔坐标系的图表。因此,其实现的大部分内容与我们之前介绍过的图表非常相似;因此,为了节省空间,本书中省略了有关外围图形元素的代码。请参考配套代码以获取完整的实现。现在让我们看看这个菜谱的实现:

... 

_symbolTypes = d3.scaleOrdinal() // <-A 
                  .range([d3.symbolCircle, 
                            d3.symbolCross, 
                            d3.symbolDiamond, 
                            d3.symbolSquare, 
                            d3.symbolStar, 
                            d3.symbolTriangle, 
                            d3.symbolWye 
                ]); 

... 

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) { 
        var symbols = _bodyG.selectAll("path._" + i) 
                        .data(list); 

        symbols.enter() 
                .append("path") 
            .merge(symbols) 
                .attr("class", "symbol _" + i) 
                .classed(_symbolTypes(i), true) 
            .transition() // <-C 
                .attr("transform", function(d){ 
                           return "translate(" // <-D 
                                    + _x(d.x) 
                                    + "," 
                                    + _y(d.y) 
                                    + ")"; 
                }) 
                .attr("d", 
                        d3.symbol() // <-E 
                            .type(_symbolTypes(i)) 
                ); 
        }); 
} 
... 

这个菜谱生成了以下散点图图表:

如何做...

散点图图表

它是如何工作的...

散点图图表的内容主要由行B上的renderSymbols函数渲染。你可能已经注意到,renderSymbols函数的实现与我们之前在创建折线图菜谱中讨论的renderDots函数非常相似。这不是偶然的,因为两者都试图在笛卡尔坐标系上使用两个变量(x 和 y)绘制数据点。在绘制点的情况下,创建了svg:circle元素,而在散点图中,你需要创建d3.symbol元素。D3 提供了一系列预定义的符号,可以轻松生成并使用svg:path元素渲染。如线A所示,我们定义了一个序数尺度,允许将数据系列索引映射到不同的符号类型:

_symbolTypes = d3.scaleOrdinal() // <-A 
                        .range([d3.symbolCircle, 
                            d3.symbolCross, 
                            d3.symbolDiamond, 
                            d3.symbolSquare, 
                            d3.symbolStar, 
                            d3.symbolTriangle, 
                            d3.symbolWye 
                        ]); 

使用符号绘制数据点相当直接。首先,我们将遍历数据系列数组,对于每个数据系列,我们将创建一组svg:path元素,代表系列中的每个数据点,如下所示:


_data.forEach(function (list, i) { 
    var symbols = _bodyG.selectAll("path._" + i) 
                        .data(list); 

    symbols.enter() 
            .append("path") 
        .merge(symbols) 
            .attr("class", "symbol _" + i) 
            .classed(_symbolTypes(i), true) 
        .transition() // <-C 
            .attr("transform", function(d){ 
                            return "translate(" // <-D 
                                    + _x(d.x) 
                                    + "," 
                                    + _y(d.y) 
                                    + ")"; 
            }) 
            .attr("d",d3.symbol() // <-E 
                                .type(_symbolTypes(i)) 
            ); 
}); 

通过合并symbols.enter()symbols选择,我们确保在数据系列更新时,以及对于新创建的符号,我们使用过渡(线C)进行更新,将它们放置在正确的坐标上,使用 SVG 平移变换(线 D)。最后,每个svg:path元素的d属性是通过d3.svg.symbol生成函数生成的,如线E所示。

创建气泡图

气泡图是一种典型的可视化工具,能够显示三个数据维度。每个具有三个数据点的数据实体在笛卡尔坐标系上被可视化为一个气泡(或圆盘),其中两个不同的变量使用x轴和y轴表示,类似于散点图图表,而第三个维度则使用气泡的半径(圆盘的大小)表示。当用于促进数据实体之间关系的理解时,气泡图尤其有用。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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.scaleOrdinal(d3.schemeCategory10), 
                _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) { 
            var bubbles = _bodyG.selectAll("circle._" + i) 
                   .data(list); 

            bubbles.enter() 
                        .append("circle") // <-C 
                    .merge(bubbles) 
                        .attr("class", "bubble _" + i) 
                        .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) { 
            var bubbles = _bodyG.selectAll("circle._" + i) 
                   .data(list); 

            bubbles.enter() 
                        .append("circle") // <-C 
                    .merge(bubbles) 
                        .attr("class", "bubble _" + i) 
                        .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)。最后,在最后一节中处理新创建的气泡及其更新,其中使用其cxcy属性将svg:circle元素着色并放置到正确的坐标(行DE)。最后,使用我们之前定义的_r刻度来控制气泡大小,通过其半径属性r映射(行F)。

小贴士

在一些气泡图实现中,实现者还利用每个气泡的颜色来可视化第四个数据维度,尽管有些人认为这种视觉表示难以理解且多余。

创建柱状图

柱状图是一种使用水平(行图)或垂直(柱状图)矩形条来表示的视觉化,其长度与它们所代表的值成比例。在这个配方中,我们将使用 D3 实现柱状图。柱状图能够使用其y轴同时可视化两个变量;换句话说,条形的高度和它的x轴。x轴的值可以是离散的或连续的(例如,直方图)。在我们的例子中,我们选择在 x 轴上可视化连续值。然而,当您处理离散值时,也可以应用相同的技巧。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter8/bar-chart.html

如何做到...

以下代码示例展示了直方图的重要实现方面,省略了访问器和外围图形实现细节:

... 

var _chart = {}; 

    var _width = 600, _height = 250, 
            _margins = {top: 30, left: 30, right: 30, bottom: 30}, 
            _x, _y, 
            _data = [], 
            _colors = d3.scaleOrdinal(d3.schemeCategory10), 
            _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 

        var bars = _bodyG.selectAll("rect.bar") 
                .data(_data); 
        bars.enter() 
                .append("rect") // <-B 
            .merge(bars) 
                .attr("class", "bar") 
            .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);  
                }) 
                .attr("width", function(d){ 
                    return Math.floor(quadrantWidth() /                              
                              _data.length) - padding; 
                }); 
    } 
... 

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

如何做到...

柱状图(直方图)

它是如何工作的...

这里的一个主要区别是条形图实现不支持多个数据系列。因此,与迄今为止我们使用其他图表一样,我们在这个实现中,_data数组简单地直接存储一组数据点。主要的条形图相关可视化逻辑位于renderBars函数中:

functionrenderBars() { 
  var padding = 2; // <-A 
  ... 
} 

在第一步中,我们定义了条形之间的填充(线A),以便稍后我们可以自动计算每个条形的宽度。之后,我们为每个数据点(线B)生成一个svg:rect元素(条形),如下所示:

var bars = _bodyG.selectAll("rect.bar") 
                .data(_data); 

        bars.enter() 
                .append("rect") // <-B 
            .merge(bars) 
                .attr("class", "bar") 
            .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);  
                }) 
                .attr("width", function(d){ 
                    return Math.floor(quadrantWidth() / 
                           _data.length) - padding; 
                }); 

然后,在更新部分,我们使用每个条形的xy属性(线CD)将每个条形放置在正确的坐标上,并使用线 E 上计算的适应性高度将每个条形延伸到底部,使其接触到x轴。最后,我们使用条形数量和我们之前定义的填充值计算每个条形的最佳宽度:

.attr("width", function(d){ 
    returnMath.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.pie设计的目的。在本食谱中,我们将看到如何使用饼布局来实现一个功能齐全的饼图。

准备工作

在您的网络浏览器中打开以下文件的本地副本:github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter9/pie-chart.html

如何做到这一点...

饼图或圆形图是将图表分成扇区(切片)的圆形图表。饼图在许多领域都很受欢迎,广泛用于展示不同实体之间的关系,尽管并非没有批评。让我们先看看如何使用d3.layout实现饼图:

<script type="text/javascript"> 
    function pieChart() { 
        var _chart = {}; 

        var _width = 500, _height = 500, 
                _data = [], 
                _colors = d3.scaleOrdinal(d3.schemeCategory10), 
                _svg, 
                _bodyG, 
                _pieG, 
                _radius = 200, 
                _innerRadius = 100, 
                _duration = 1000; 

        _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.pie() // <-A 
                    .sort(function (d) { 
                        return d.id; 
                    }) 
                    .value(function (d) { 
                        return d.value; 
                    }); 

            var arc = d3.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.pie将原始数据转换成弧数据。饼图布局在行 A 创建,同时指定了排序和值访问器:

var pie = d3.pie() // <-A 
              .sort(function (d) { 
                  return d.id; 
              }) 
              .value(function (d) { 
                  return d.value; 
              }); 

sort函数告诉饼图布局按其 ID 字段对切片进行排序,这样我们就可以在切片之间保持稳定的顺序。如果没有排序,默认情况下,饼图布局将按值排序切片,导致每次我们更新饼图时切片都会交换。value函数用于提供值访问器,在我们的例子中,它返回value字段。在渲染切片时,现在使用饼图布局,我们直接将pie函数调用的输出作为数据(记住,布局是数据)来生成svg:path元素(参见图B):

function renderSlices(pie, arc) { 
    var slices = _pieG.selectAll("path.arc") 
            .data(pie(_data)); // <-B 

    slices.enter() 
            .append("path") 
        .merge(slices) 
            .attr("class", "arc") 
            .attr("fill", function (d, i) { 
                return _colors(i); 
            }) 
        .transition() 
            .duration(_duration) 
            .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)); 
                }; 
            }); 
} 

在这一点上,你可能想知道d3.pie生成什么类型的数据。下面是输出数据的样子:

工作原理...

饼图输出数据

如我们所清楚地看到,这正是d3.arc生成器所期望的。这就是为什么我们可以直接使用d3.arc而不需要处理任何关于角度和分区的详细计算。渲染逻辑的其余部分基本上与你在第七章,形状塑造中学到的是一样的,只有一个例外可以在行 C 中看到。在行 C 中,我们从元素中检索当前的弧值,以便过渡可以从当前角度而不是零开始。然后,在行 D 中,我们将当前的弧值重置为最新值;因此,下次我们更新饼图数据时,我们可以重复状态化过渡。

提示

技术 - 状态化可视化 在 DOM 元素上注入值的技术是引入可视化状态性的常见方法。换句话说,如果你需要你的可视化记住它们之前的状态,你可以在 DOM 元素中保存它们,就像在这个食谱中行C所展示的那样。

最后,我们还需要在每个切片上渲染标签,以便我们的用户可以理解每个切片代表什么。这是通过renderLabels函数完成的:

function renderLabels(pie, arc) { 
            var labels = _pieG.selectAll("text.label") 
                    .data(pie(_data)); // <-E 

            labels.enter() 
                    .append("text") 
                .merge(labels) 
                    .attr("class", "label") 
                .transition() 
                    .duration(_duration) 
                    .attr("transform", function (d) { 
                        return "translate("  
                            + arc.centroid(d) + ")"; // <-F 
                    }) 
                    .attr("dy", ".35em") 
                    .attr("text-anchor", "middle") 
                    .text(function (d) { 
                        return d.data.id; 
                    });         
} 

再次使用pie函数调用的输出作为数据来生成svg:text元素。标签的位置是通过arc.centroid计算的(参见图F)。此外,标签的位置通过过渡进行动画处理,这样它们就可以与弧一起移动。

更多...

饼图在许多不同的领域中被广泛使用。然而,由于它们难以用肉眼比较给定饼图的各个部分,以及它们的信息密度低,因此它们也受到了广泛的批评。因此,强烈建议将部分数量限制在三个以下,其中两个被认为是理想的。否则,您始终可以使用条形图或小型表格来替换饼图,以获得更好的精度和沟通能力。

相关内容

  • 在第七章 Getting into ShapeUsing arc generators 食谱中,[第七章。塑形]

  • 在第七章 Getting into ShapeImplementing arc transition 食谱中,第七章

构建堆叠面积图

在第八章 Chart Them UpCreating an area chart 食谱中,我们探讨了如何使用 D3 实现基本的分层面积图。在本食谱中,我们将基于面积图食谱中的内容来构建堆叠面积图。堆叠面积图是标准面积图的一种变体,其中不同的区域堆叠在一起,使观众能够比较不同的数据系列,以及它们与总量的比例关系。

准备工作

在您的网络浏览器中打开以下文件的本地副本:github.com/NickQiZhu/d3-cookbook-v2/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.scaleOrdinal(d3.schemeCategory10), 
            _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.stack() // <-A 
                .keys(['value1', 'value2', 'value3']) 
                .offset(d3.stackOffsetNone); 

    var series = stack(_data); //<-B 

    renderLines(series); 

    renderAreas(series); 
} 

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.stack实现的:

var stack = dn3.stack() // <-A 
                .keys(['value1', 'value2', 'value3']) // <-B 
                .offset(d3.stackOffsetNone); 
... 
function update() { 
    data = d3.range(numberOfDataPoint).map(function (i) { 
        return {value1: randomData(),  
                value2: randomData(),  
                value3: randomData()}; 
    }); 

    chart.data(data).render(); 
} 

如此代码片段所示,我们在update函数中生成的数据点有三个不同的系列,value1value2value3。这就是为什么我们需要在行B上指定它们的名称给d3.stack。我们在堆叠布局上所做的唯一其他定制是将其offset设置为d3.stackOffsetNone。D3 堆叠布局支持几种不同的偏移模式,这些模式决定了要使用的堆叠算法;这是我们将在本食谱和下一食谱中探讨的内容。在这种情况下,我们使用zero偏移堆叠,它生成一个零基线的堆叠算法,这正是本食谱所想要的。接下来,在行B上,我们对给定的数据数组调用了堆叠布局,生成了以下布局数据:

工作原理...

堆叠数据

如所示,堆叠布局自动为我们的三个不同数据系列中的每个数据计算一个y基线0以及y上界1。现在,有了这个堆叠数据集,我们可以轻松地生成堆叠线:

function renderLines(series) { 
        _line = d3.line() 
                .x(function (d, i) { 
                    return _x(i); //<-C 
                }) 
                .y(function (d) { 
                    return _y(d[1]); //<-D 
                }); 

        var linePaths = _bodyG.selectAll("path.line") 
                .data(series); 

        linePaths.enter() 
                .append("path") 
            .merge(linePaths) 
                .style("stroke", function (d, i) { 
                    return _colors(i); 
                }) 
                .attr("class", "line") 
            .transition() 
                .attr("d", function (d) { 
                    return _line(d); 
                }); 
} 

使用d3.line生成函数创建了一个索引计数值i直接映射到x(参考行C),其y上界值映射到d[1](参考行D)。这就是进行线堆叠所需做的所有事情。renderLines函数的其余部分基本上与基本区域图实现相同。区域堆叠逻辑略有不同:

function renderAreas(series) { 
        var area = d3.area() 
                .x(function (d, i) { 
                    return _x(i); //<-E 
                }) 
                .y0(function(d){return _y(d[0]);}) //<-F 
                .y1(function (d) { 
                    return _y(d[1]); //<-G 
                }); 

        var areaPaths = _bodyG.selectAll("path.area") 
                .data(series); 

        areaPaths.enter() 
                .append("path") 
            .merge(areaPaths) 
                .style("fill", function (d, i) { 
                    return _colors(i); 
                }) 
                .attr("class", "area") 
            .transition() 
                .attr("d", function (d) { 
                    return area(d); 
                }); 
} 

与渲染区域时类似的线渲染逻辑,我们唯一需要更改的地方是在d3.area生成器设置中。对于区域,x值仍然直接映射到索引计数i(行 E),其y0直接映射到y基线d[0],最后y1y上界d[1](行 G)。

如我们所见,D3 堆叠布局设计得非常好,可以与不同的 D3 SVG 生成函数兼容。因此,使用它来生成堆叠效果非常直接和方便。

还有更多...

让我们看看堆叠区域图的几个变体。

扩展区域图

我们已经提到d3.stack支持不同的偏移模式。除了我们之前看到的d3.stackOffsetNone偏移之外,对于区域图来说,另一个非常有用的偏移模式称为d3.stackOffsetExpand。使用d3.stackOffsetExpand模式,堆叠布局将不同层标准化以填充范围[0, 1]。如果我们更改此食谱中的偏移模式以及 y 轴域为[0, 1],我们将得到以下扩展(标准化)区域图;这种可视化在观众更关心每个数据系列的相对比例而不是其绝对值时非常有用:

扩展区域图

扩展区域图

对于完整的配套代码示例,请访问github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter9/expanded-area-chart.html

流图

堆叠区域图的另一个有趣的变体称为流图。流图是一种围绕中心轴显示的堆叠区域图,创建出流动和有机的形状。流图最初由 Lee Byron 开发,并于 2008 年在一篇关于电影票房收入的纽约时报文章中普及。D3 堆叠布局内置了对这种堆叠算法的支持,因此,将基于零的堆叠区域图转换为流图非常简单。关键区别在于流图使用d3.stackOffsetWiggle作为其布局偏移模式。流图在你想强调数据的变化或其随时间的变化趋势而不是其绝对值时非常有用。

Streamgraph

流图

完整的配套代码示例,请访问 github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter9/streamgraph.html

参考信息

构建树状图

树状图是由 Ben Shneiderman 在 1991 年提出的。树状图将层次树结构数据显示为一系列递归划分的矩形。换句话说,它将树的每个分支显示为一个大的矩形,然后用表示子分支的小矩形进行平铺。这个过程会一直重复,直到达到树的叶子节点。

注意

关于树状图的更多信息,请参阅 Ben Shneiderman 在 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-v2/blob/master/src/chapter9/treemap.html

如何实现...

现在,让我们看看如何使用 d3.treemap 函数来直观地表示这种层次数据:

function treemapChart() { 
        var _chart = {}; 

        var _width = 1600, _height = 800, 
                _colors = d3.scaleOrdinal(d3.schemeCategory20c), 
                _svg, 
                _nodes, 
                _valueAccessor, 
                _treemap, 
                _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 the 'how it works...' section 
            ...  

            renderCells(cells); 
        } 

        function renderCells(cells){ 
            // explained in the 'how it works...' section 
            ... 
        } 

        // accessors omitted 
        ... 

        return _chart; 
} 

d3.json("flare.json", function (nodes) { 
  var chart = treemapChart(); 
  chart.nodes(nodes).render(); 
}); 

这个菜谱生成了以下 treemap 可视化:

如何实现...

Treemap

它是如何工作的...

在这一点上,你可能会惊讶地发现实现如此复杂的数据可视化所需的代码如此之少。这是因为大部分繁重的工作都是由 d3.treemapd3.hierarchy 函数完成的:

function renderBody(svg) { 
        if (!_bodyG) { 
            _bodyG = svg.append("g") 
                    .attr("class", "body"); 

            _treemap = d3.treemap() //<-A 
                    .size([_width, _height]) 
                    .round(true) 
                    .padding(1); 
        } 

        var root = d3.hierarchy(_nodes) // <-B 
                .sum(_valueAccessor) 
                .sort(function(a, b) {  
                     return b.value - a.value;  
                }); 

        _treemap(root); //<-C 

        var cells = _bodyG.selectAll("g") 
                .data(root.leaves()); // <-D 

        renderCells(cells); 
    } 

在第 A 行定义了 d3.treemap 布局,并有一些基本的自定义设置:

  • round(true): 当启用舍入时,树图布局将舍入到精确的像素边界。当你想要避免 SVG 中的抗锯齿伪影时,这非常好。

  • size([_width, _height]): 它将布局边界设置为这个 SVG 的大小。

  • padding(1): 我们将填充设置为 1,这样在树图中生成的块之间就会有空白填充。

在这个菜谱中,我们使用 d3.hierarchy 函数在第 B 行重新结构化输入数据,使其能够被 d3.treemap 和其他 D3 层次数据函数消费:

  • sum(_valueAccessor): 这个菜谱提供的一个特性是能够动态切换树图值访问器。值访问器由 d3.hierachy 函数用于访问每个节点的值字段。在我们的例子中,它可以是以下函数之一:
function(d){ return d.size; } // visualize package size 
function(d){ return 1; } // visualize package count 

  • sort(function(a, b) { return b.value - a.value; }): 我们还指示 d3.hierarch 按照每个节点的值顺序排序,从而有效地使树图按照块的大小顺序排列。

要在 Flare JSON 数据源上应用 d3.hierarchy 转换,我们只需将 d3.hierarchy 函数上的 nodes 设置为我们 JSON 树的根节点(参见图 B)。然后,我们使用变量 root 存储经过 d3.hierarchy 转换后的数据。现在数据看起来是这样的:

它是如何工作的...

Treemap 层次结构转换

如我们所见,在转换之后,每个节点现在都根据其所有子节点的值的总和以及计算出的深度和大小来计算其值,如下所示:

  • depth: 它表示节点的深度

  • height: 它表示树中节点的长度

  • value: 它表示所有子树值的总和

  • x0: 它表示单元格开始的 x 坐标

  • y0: 它表示单元格开始的 y 坐标

  • x1: 它表示单元格结束的 x 坐标

  • y1: 它表示单元格结束的 y 坐标

经过这次转换后,现在我们可以将 root 变量传递到第 C 行的 _treemap 函数。现在,我们准备生成可视化。在第 D 行,我们仅使用树图的叶节点生成单元格:

var cells = _bodyG.selectAll("g") 
                .data(root.leaves()); // <-D 

这是因为首先,d3.selection.data 期望的是扁平数据数组而不是层次化树。其次,树图实际上只渲染叶节点;子树分组是通过颜色来可视化的。如果我们仔细观察可视化,这一点并不难发现。

renderCells函数中,为给定的节点创建了一组svg:g元素。然后renderCells函数负责创建矩形及其标签:

function renderCells(cells) { 
        var cellEnter = cells.enter().append("g") 
                .attr("class", "cell") 
                .attr("transform", function (d) { 
                    return "translate(" + d.x0 + ","  
                                     + d.y0 + ")"; //<-E 
                }); 

        renderRect(cellEnter, cells); 

        renderText(cellEnter, cells); 

        cells.exit().remove(); 
    } 

每个矩形放置在其位置(x, y),该位置由 E 行上的布局确定:

function renderRect(cellEnter, cells) { 
        cellEnter.append("rect"); 

        cellEnter.merge(cells) 
                .transition() 
                .attr("transform", function (d) { 
                    return "translate(" + d.x0 + "," + d.y0 + ")";  
                }) 
                .select("rect") 
                .attr("width", function (d) { //<-F 
                    return d.x1 - d.x0; 
                }) 
                .attr("height", function (d) { 
                    return d.y1 - d.y0; 
                }) 
                .style("fill", function (d) { 
                    return _colors(d.parent.data.name); //<-G 
                }); 
    } 

然后,在renderRect函数中,我们在 F 行将其宽度和高度分别设置为d.x1 - d.x0d.y1 - d.y0。在 G 行,我们使用其父级的名称为每个单元格着色,从而确保属于同一父级的所有子项都以相同的方式着色。下一步是渲染标签:

function renderText(cellEnter, cells) { 
        cellEnter.append("text"); 

        cellEnter.merge(cells) 
                .select("text") //<-H 
                .style("font-size", 11) 
                .attr("x", function (d) { 
                    return (d.x1 - d.x0) / 2; 
                }) 
                .attr("y", function (d) { 
                    return (d.y1 - d.y0) / 2; 
                }) 
                .attr("text-anchor", "middle") 
                .text(function (d) { 
                    return d.data.name; 
                }) 
                .style("opacity", function (d) { 
                    d.w = this.getComputedTextLength(); 
                    return d.w < (d.x1 - d.x0) ? 1 : 0; //<-I 
                }); 
    } 

从 H 行开始,我们为每个矩形创建了标签(svg:text)元素,并将其文本设置为节点名称。在这里值得提到的一个方面是,为了避免显示比单元格本身还小的标签,如果标签的宽度大于单元格宽度,则将标签的不透明度设置为 0(请参阅 I 行)。

小贴士

技术 - 自动隐藏标签 在 I 行我们看到的是可视化中实现自动隐藏标签的有用技术。这个技术可以一般地考虑以下形式:.style("opacity", function (d) { width = this.getComputedTextLength(); return d.dx > width ? 1 : 0;

相关内容

构建树

当处理层次化数据结构时,树(树图)可能是最自然和常用的可视化之一,通常用于展示不同数据元素之间的结构依赖关系。树是一个无向图,其中任何两个节点(顶点)通过一条且仅有一条简单路径连接。在本菜谱中,我们将学习如何使用 D3 树布局实现树形可视化。

准备就绪

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter9/tree.html .

如何做...

现在让我们看看d3.tree的实际应用:

function tree() { 
        var _chart = {}; 

        var _width = 1600, _height = 1600, 
                _margins = {top: 30, left: 120, right: 30, bottom: 30}, 
                _svg, 
                _nodes, 
                _i = 0, 
                _duration = 300, 
                _bodyG, 
                _root; 

        _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 + ")"; 
                        }); 
            } 

            _root = d3.hierarchy(_nodes); // <-A 

            render(_root); 
        } 

        function render(root) { 
            var tree = d3.tree() // <-B 
                        .size([ 
                            (_height - _margins.top - _margins.bottom), 
                            (_width - _margins.left - _margins.right) 
                        ]); 

            tree(root); // <-C 

            renderNodes(root); // <-D 

            renderLinks(root); // <-E 
        } 

  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.tree 函数专门设计用于将层次数据结构转换为适合生成树图的视觉布局数据。然而,与 构建一个树状图 菜谱类似,d3.tree 布局函数只接受结构化的 D3 层次数据,这意味着在我们可以使用布局函数之前,我们需要使用 d3.hierachy 首先处理和格式化我们的数据。同样,在这个菜谱中,我们使用的是本章迄今为止使用的相同的 Flare 项目包数据。原始 JSON 数据源如下所示:

{ 
 "name": "flare", 
 "children": [ 
  { 
   "name": "analytics", 
   "children": [ 
    { 
     "name": "cluster", 
     "children": [ 
      {"name": "AgglomerativeCluster", "size": 3938}, 
      {"name": "CommunityStructure", "size": 3812}, 
      {"name": "HierarchicalCluster", "size": 6714}, 
      {"name": "MergeEdge", "size": 743} 
     ] 
}, 
... 
} 

此数据被加载并传递到以下函数中的我们的图表对象:

function flare() { 
        d3.json("../../data/flare.json", function (nodes) { 
            chart.nodes(nodes).render(); 
        }); 
} 

一旦数据加载完成,我们首先将加载的 JSON 数据传递给 d3.hierachy 进行处理(参考行 A):

_root = d3.hierarchy(_nodes); // <-A 

在这个菜谱中,我们只需要这些,因为 d3.tree 布局只关心节点之间的层次关系,因此,我们不需要像在 构建一个树状图 菜谱中那样对数据进行求和或排序。一旦处理完成,我们现在可以使用以下代码创建树布局:

var tree = d3.tree() // <-B 
                .size([ 
                    (_height - _margins.top - _margins.bottom), 
                    (_width - _margins.left - _margins.right) 
                ]); 

我们在这里提供的唯一设置是我们的可视化大小,即我们的 SVG 图像大小减去边距。d3.tree 函数将处理其余部分并相应地计算每个节点的位置。要使用树布局,你只需在行 C 上调用布局函数即可。

tree(root); // <-C 

如果你查看 nodes 布局数据,它包含如下所示的数据:

如何工作...

树布局数据

树节点在 renderNode 函数中如下渲染:

function renderNodes(root) { 
            var nodes = root.descendants(); 

            var nodeElements = _bodyG.selectAll("g.node") 
                    .data(nodes, function (d) { 
                                    return d.id || (d.id = ++_i); 
                                }); 

            var nodeEnter = nodeElements.enter().append("g") 
                    .attr("class", "node") 
                    .attr("transform", function (d) {  // <-F 
                        return "translate(" + d.y 
                                + "," + d.x + ")"; 
                    }) 
                    .on("click", function (d) { // <-G 
                        toggle(d); 
                        render(_root); 
                    }); 

            nodeEnter.append("circle") // <-H 
                    .attr("r", 4); 

            var nodeUpdate = nodeEnter.merge(nodeElements) 
                .transition().duration(_duration) 
                .attr("transform", function (d) { 
                    return "translate(" + d.y + "," + d.x + ")"; // <-I 
                }); 

            nodeUpdate.select('circle') 
                .style("fill", function (d) { 
                  return d._children ? "lightsteelblue" : "#fff"; // <-J 
                }); 

            var nodeExit = nodeElements.exit() 
                    .transition().duration(_duration) 
                    .attr("transform", function (d) { 
                        return "translate(" + d.y 
                                + "," + d.x + ")"; 
                    }) 
                    .remove(); 

            nodeExit.select("circle") 
                    .attr("r", 1e-6) 
                    .remove(); 

            renderLabels(nodeEnter, nodeUpdate, nodeExit); 
} 

在这个函数中,我们首先生成一组与 root.descendents() 绑定的 g.node 元素:

var nodes = root.descendants(); 
var nodeElements = _bodyG.selectAll("g.node") 
        .data(nodes, function (d) { 
            return d.id || (d.id = ++_i); 
        }); 

root.descendents 函数返回层次数据中的所有节点。这与我们在 构建一个树状图 菜单中使用的 root.leaves 函数不同。root.leaves 函数只返回作为 JavaScript 数组的叶节点;然而,使用 d3.tree 布局时,我们不仅关注叶节点,还关注任何中间节点,以便可视化整个树结构,因此,我们需要使用 root.descendents。在此阶段,我们还使用索引为每个节点分配一个 ID 以获得对象一致性;如对此概念不熟悉,请参考 第六章,以风格过渡,了解更多关于对象一致性的信息;

var nodeEnter = nodeElements.enter().append("g") 
            .attr("class", "node") 
            .attr("transform", function (d) {  // <-F 
                return "translate(" + d.y 
                        + "," + d.x + ")"; 
            }) 
            .on("click", function (d) { // <-G 
                toggle(d); 
                render(_root); 
            }); 

在行 F 上,我们创建了节点并将它们移动到 d3.tree 布局为我们计算的 (d.y, d.x) 坐标。在这种情况下,我们交换了 xy,因为默认情况下 d3.tree 布局以竖直模式计算坐标,而在这个菜谱中我们希望以横幅模式渲染。在行 G 上,我们还创建了 onClick 事件处理程序来处理用户对树节点的鼠标点击。toggle 函数由以下代码组成:

        function toggle(d) { 
            if (d.children) { 
                d._children = d.children; 
                d.children = null; 
            } else { 
                d.children = d._children; 
                d._children = null; 
            } 
        } 

此函数有效地暂时隐藏了给定数据节点上的子节点字段。这样做本质上从该节点中移除所有子节点,在可视化中因此给用户一种点击节点时折叠其子树的感觉:

nodeEnter.append("circle") // <-H 
            .attr("r", 4); 

    var nodeUpdate = nodeEnter.merge(nodeElements) 
            .transition().duration(_duration) 
                .attr("transform", function (d) { 
                    return "translate(" + d.y + "," + d.x + ")"; // <-I 
                }); 

    nodeUpdate.select('circle') 
            .style("fill", function (d) { 
                return d._children ? "lightsteelblue" : "#fff"; // <-J 
         }); 

在第 H 行,我们创建了 SVG 圆形元素来表示每个树节点,并且再次将它们定位在(d.y, d.x)。最后,在第 J 行,我们根据节点是折叠还是打开,通过检查由toggle函数生成的临时_children文件来用不同的填充色着色节点。其余的节点和标签渲染代码相当简单,所以在这里我们不会逐行介绍;更多详情请参考 GitHub 上的源代码。

在这个食谱中下一个重要的函数是renderLinks函数。此函数绘制了我们刚刚创建的所有连接树节点的链接:

function renderLinks(root) { 
            var nodes = root.descendants().slice(1); 

            var link = _bodyG.selectAll("path.link") 
                .data(nodes, function (d) { 
                    return d.id || (d.id = ++_i); 
                }); 

            link.enter().insert("path", "g") // <-M 
                        .attr("class", "link") 
                    .merge(link) 
                    .transition().duration(_duration) 
                       .attr("d", function (d) { 
                        return generateLinkPath(d, d.parent); // <-N 
                    }); 

            link.exit().remove(); 
} 

首先,为了渲染链接,我们使用root.descendants().slice(1)作为其数据,而不是root.descendants()。这是因为对于n个节点,有n - 1个链接,因为在树中不存在指向根节点的链接。再次使用对象一致性来使我们的可视化在重新渲染期间更加稳定。然后,在第 M 行,我们创建了路径元素来表示我们可视化中的每个链接。现在,这个函数有趣的部分在于第 N 行的generateLinkPath函数:

function generateLinkPath(target, source) { 
    var path = d3.path(); 
    path.moveTo(target.y, target.x); 
    path.bezierCurveTo((target.y + source.y) / 2, target.x, 
            (target.y + source.y) / 2, source.x, source.y, source.x); 
    return path.toString(); 
} 

在这个函数中,我们使用d3.path生成器生成一个连接源节点和目标节点的贝塞尔曲线。你可能注意到,d3.path生成器的使用几乎就像描述如何绘制线条。在这种情况下,我们将这条线的起点移动到(target.y, target.x),然后从目标到源使用给定的控制点绘制贝塞尔曲线,如下面的插图所示:

工作原理...

贝塞尔曲线

注意

当然,如果你熟悉 SVG 路径命令,那么你可以不使用d3.path生成器生成 d 公式。在这种情况下,我们本质上使用 M 和 C 命令。然而,d3.path生成器函数更易于阅读,并且与 SVG 和 Canvas 都兼容得很好,因此通常产生更易于维护的代码。有关 SVG 路径命令的更多信息,请参阅 www.w3.org/TR/SVG/paths.html#PathDataCubicBezierCommands

到目前为止,我们现在已经将整个树形图可视化。正如你所见,借助d3.tree布局,绘制这种复杂可视化相对直接,如果不是容易的话。

参见

构建封装图

封装图是使用递归圆形打包算法的层次数据结构的有趣可视化。它使用包含(嵌套)来表示层次结构。为数据树中的每个叶节点创建圆形,其大小与每个数据元素的一个特定定量维度成比例。在这个菜谱中,您将学习如何使用 D3 打包布局实现这种可视化。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter9/pack.html

如何操作...

在这个菜谱中,让我们看看如何使用d3.pack实现一个封装图:

function pack() { 
        var _chart = {}; 

        var _width = 1280, _height = 800, 
                _svg, 
                _valueAccessor, 
                _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"); 
          } 

          var pack = d3.pack() // <-A 
                  .size([_width, _height]); 

          var root = d3.hierarchy(_nodes) // <-B 
                        .sum(_valueAccessor) 
                        .sort(function(a, b) {  
                          return b.value - a.value;  
                        }); 

          pack(root); // <-C 

          renderCircles(root.descendants()); 

          renderLabels(root.descendants()); 
       } 

    function renderCircles(nodes) { 
      // will be explained in the 'how it works...' section 
      ... 
    } 

    function renderLabels(nodes) { 
      // omitted 
      ... 
    } 

    // accessors omitted 
    ... 

    return _chart; 
} 

此菜谱生成了以下可视化:

如何操作...

封装图

工作原理...

在这个菜谱中,我们继续使用描述 Flare 项目包关系的层次 JSON 数据源。有关数据源的信息,请参考本章的构建树状图菜谱。JSON 数据结构如下所示:

{ 
 "name": "flare", 
 "children": [ 
  { 
   "name": "analytics", 
   "children": [ 
    { 
     "name": "cluster", 
     "children": [ 
      {"name": "AgglomerativeCluster", "size": 3938}, 
      {"name": "CommunityStructure", "size": 3812}, 
      {"name": "HierarchicalCluster", "size": 6714}, 
      {"name": "MergeEdge", "size": 743} 
     ] 
    }, 
    ... 
   } 
  ] 
} 

此数据在flare函数中加载到图表对象中:

function flare() { 
    d3.json("../../data/flare.json", function (nodes) { 
       chart.nodes(nodes).valueAccessor(size).render(); 
    }); 
} 

在这个可视化中,我们首先需要关注的是定义我们的布局;在这种情况下,我们需要使用d3.pack布局:

var pack = d3.pack() // <-A 
            .size([_width, _height]); 

我们在布局上设置了可视化的尺寸,以便它可以相应地计算。之后,在我们再次将 JSON 数据传递给d3.pack布局之前,我们需要首先使用d3.hierarchy函数(参考 B 行)对其进行处理,这是任何 D3 层次可视化必备的先决条件:

var root = d3.hierarchy(_nodes) // <-B 
                .sum(_valueAccessor) 
                .sort(function(a, b) { return b.value - a.value; }); 
pack(root); // <-C 

在这种情况下,我们通过_valueAccessor函数告诉d3.hierarchy函数将所有值相加,该函数默认以d.size作为值。此外,我们还要求d3.hierachy函数根据值对节点进行排序。最后,我们将处理后的数据传递给 C 行上的pack函数。经过此过程后的布局数据现在看起来如下:

工作原理...

打包布局数据

圆形渲染是在renderCircle函数中完成的:

function renderCircles(nodes) { // <-C 
    var circles = _bodyG.selectAll("circle") 
            .data(nodes); 
    circles.enter().append("circle") 
            .merge(circles) 
            .transition() 
        .attr("class", function (d) { 
            return d.children ? "parent" : "child"; 
        }) 
        .attr("cx", function (d) {return d.x;}) // <-D 
        .attr("cy", function (d) {return d.y;}) 
        .attr("r", function (d) {return d.r;}); 
    circles.exit().transition() 
            .attr("r", 0) 
            .remove(); 
} 

然后,我们简单地绑定布局数据并为每个节点创建 svg:circle 元素。对于更新,我们将 cxcyradius 设置为打包布局为我们每个圆计算出的值(参见图 D)。最后,在移除圆之前,我们首先将圆的大小减小到零,然后再移除它们以生成更平滑的过渡。在这个菜谱中,标签渲染相当直接,得益于我们在本章中引入的自动隐藏技术,因此我们不会在这里详细说明该函数。

参见

第十章。与您的可视化交互

在本章中,我们将涵盖:

  • 与鼠标交互

  • 与多触控设备交互

  • 实现缩放和平移行为

  • 实现拖拽行为

简介

可视化设计的最终目标是优化应用程序,以便它们能帮助我们更有效地完成认知工作。

Ware C. (2012)

数据可视化的目标是帮助观众通过隐喻、心智模型对齐和认知放大,快速有效地从大量原始数据中获取信息。到目前为止,在这本书中,我们已经介绍了各种技术,通过实现多种类型的可视化来利用 D3 库。然而,我们还没有触及可视化的重要方面:人机交互。各种研究已经得出结论,人机交互在信息可视化中具有独特的价值。

将可视化与计算引导相结合,可以更快地分析更复杂的场景……本案例研究充分证明了复杂模型与引导和交互式可视化的交互可以扩展建模的应用范围,超越研究领域

Barrass I. & Leng J (2011)

在本章中,我们将专注于 D3 的人机可视化交互支持;或者,如前所述,您将学习如何将计算引导能力添加到您的可视化中。

与鼠标事件交互

鼠标是大多数桌面和笔记本电脑上最常见的、最受欢迎的人机交互控制方式。即使今天,随着多点触控设备逐渐占据主导地位,触摸事件仍然通常通过鼠标事件来模拟。在本食谱中,我们将学习如何在 D3 中处理标准鼠标事件。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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(d3.easeQuadIn) 
                    .attr("r", r) 
                    .style("stroke-opacity", 0) 
                    .on("end", function () { 
                        d3.select(this).remove(); 
                    }); 
        } 
    }); 
</script> 

当您点击 SVG 图像时,本食谱将生成以下交互式可视化:

如何实现...

鼠标交互

工作原理...

在 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: 当鼠标按钮在元素上释放时触发

参见

  • 有关本食谱中使用的涟漪效果技术的更多详细信息,请参阅第六章,以风格过渡

  • 有关事件类型的完整列表,请参阅 W3C DOM Level 3 Events 规范www.w3.org/TR/DOM-Level-3-Events/

  • 有关鼠标检测的更多详细信息,请参阅d3.mouse API 文档

与多触控设备交互

现在,随着多触控设备的普及,任何面向大众消费的可视化都需要考虑其交互性,不仅通过传统的指针设备,还要通过多触控和手势。在本食谱中,我们将探索 D3 提供的触摸支持,看看它如何被利用来生成一些与多触控设备非常有趣的交互。

准备中

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

github.com/NickQiZhu/d3-cookbook-v2/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") // <-A 
            .on("touchstart", touch) 
            .on("touchend", touch); 

    function touch() { 
        d3.event.preventDefault(); // <-B 

        var arc = d3.arc() 
                .outerRadius(initR) 
                .innerRadius(initR - thickness); 

        var g = svg.selectAll("g.touch") // <-C 
                .data(d3.touches(svg.node()), function (d, i) { 
                    return i; 
                }); 

        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(d3.easeLinear) 
                .attrTween("d", function (d) { // <-D 
                    var interpolate = d3.interpolate( 
                            {startAngle: 0, endAngle: 0}, 
                            {startAngle: 0, endAngle: 2 * Math.PI} 
                        ); 
                    return function (t) { 
                        return arc(interpolate(t)); 
                    }; 
                }) 
                .on("end", function (d) { 
                    if (complete(d)) // <-E 
                        ripples(d); 
                    g.remove(); 
                }); 

        g.exit().remove().each(function (d) { 
            console.log("Animation stopped"); 
            d[2] = "stopped"; // <-F 
        }); 
    } 

    function complete(d) { 
        console.log("Animation completed? " + (d.length < 3)); 
        return d.length < 3; 
    } 

    function ripples(position) { 
        console.log("Producing ripple effect..."); 

        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(d3.easeQuadIn) 
                    .attr("r", r) 
                    .style("stroke-opacity", 0) 
                    .on("end", function () { 
                        d3.select(this).remove(); 
                    }); 
        } 
    } 
</script> 

此配方在触摸设备上生成以下交互式可视化:

如何做...

触摸交互

工作原理...

通过 D3 选择器的 on 函数注册触摸事件监听器,这与我们在前一个示例中处理鼠标事件的方式类似(参考行 A):

d3.select("body") // <-A 
            .on("touchstart", touch) 
            .on("touchend", touch); 

这里的一个关键区别是我们将触摸事件监听器注册在 body 元素上而不是 svg 元素上,因为许多操作系统和浏览器定义了默认的触摸行为,我们希望用我们的自定义实现来覆盖它。这是通过以下函数调用实现的(参考行 B):

d3.event.preventDefault(); // <-B 

一旦触发触摸事件,我们将使用 d3.touches 函数检索多个触摸点数据,如下面的代码片段所示:

var g = svg.selectAll("g.touch") // <-C 
                .data(d3.touches(svg.node()), function (d, i) { 
                    return i; 
                });  

d3.mouse 函数返回的二维数组不同,d3.touches 返回一个二维数组的数组,因为每个触摸事件可能有多个触摸点。每个触摸位置数组的数据结构如下所示:

工作原理...

触摸位置数组

在这里,我们还在本配方中使用了数组索引来建立对象一致性。一旦触摸数据绑定到选择器,进度圆环就会在每个触摸点周围生成:

        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(d3.easeLinear) 
                .attrTween("d", function (d) { // <-D 
                    var interpolate = d3.interpolate( 
                            {startAngle: 0, endAngle: 0}, 
                            {startAngle: 0, endAngle: 2 * Math.PI} 
                        ); 
                    return function (t) { 
                        return arc(interpolate(t)); 
                    }; 
                }) 
                .on("end", function (d) { 
                    if (complete(d))  
                        ripples(d); // <-E 
                    g.remove(); 
                }); 

注意

这是通过标准的弧形过渡和弧形属性插值(参考行 D)实现的,如第七章“形状塑造”中所述。如果进度圆环尚未被用户取消,即使过渡已经完成,则在线 E 上生成类似于我们在前一个示例中所做的波纹效果。由于我们在 touchstarttouchend 事件上注册了相同的 touch 事件监听器,我们可以使用以下行来移除进度圆环并设置一个标志以指示此进度圆环已提前停止:

        g.exit().remove().each(function (d) { 
            console.log("Animation stopped"); 
            d[2] = "stopped"; // <-F 
        }); 
     ... 
     function complete(d) { 
        console.log("Animation completed? " + (d.length < 3)); 
        return d.length < 3; 
    } 

我们需要在 d 上设置这个状态标志,即触摸数据数组,因为没有方法可以取消已经开始的过渡;因此,即使您从 DOM 树中移除了进度圆环元素,过渡仍然会完成并触发行 E。

更多...

我们已经通过 touchstarttouchend 事件演示了触摸交互;然而,你可以使用相同的模式来处理浏览器支持的任何其他触摸事件。以下列表包含了 W3C 推荐的触摸事件类型:

  • touchstart:当用户在触摸表面上放置一个触摸点时,它会被触发。

  • touchend:当用户从触摸表面上移除一个触摸点时,它会被触发。

  • touchmove:当用户在触摸表面上移动一个触摸点时,它会被触发。

  • touchcancel:当触摸点以一种特定于实现的方式被干扰时,它会被触发。

参考内容

  • 请参考 第六章,过渡风格,以获取关于在此配方中使用对象恒定和涟漪效果技术的更多详细信息。

  • 请参考 第七章,进入形状,以获取关于在此配方中使用进度圆环属性缓动过渡技术的更多详细信息

  • 请参考 W3C 触摸事件建议的推荐,以获取触摸事件类型的完整列表,链接为 www.w3.org/TR/touch-events

  • 请参考 d3.touch API 文档,以获取关于多触摸检测的更多详细信息,链接为 github.com/d3/d3-selection/blob/master/README.md#touches

实现缩放和滚动行为

缩放和滚动是数据可视化中常见且有用的技术,与基于 SVG 的可视化配合得非常好,因为矢量图形不会像位图那样受到像素化的影响。当处理大型数据集时,放大特别有用,因为无法或不可能可视化整个数据集,因此需要采用缩放和钻取的方法。在此配方中,我们将探索 D3 内置的缩放和滚动支持。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter10/zoom.html

如何操作...

在此配方中,我们将使用 D3 缩放支持实现几何放大和滚动。让我们看看以下代码是如何实现的:

<script type="text/javascript"> 
    var width = 600, height = 350, 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("style", "1px solid black") 
            .attr("width", width) 
            .attr("height", height) 
            .call( // <-A 
                    d3.zoom() // <-B 
                    .scaleExtent([1, 10]) // <-C 
                    .on("zoom", zoomHandler) // <-D 
            ) 
            .append("g"); 

    svg.selectAll("circle") 
            .data(data) 
            .enter().append("circle") 
            .attr("r", r) 
            .attr("transform", function (d) { 
                return "translate(" + d + ")"; 
            }); 

    function zoomHandler() { 
        var transform = d3.event.transform; 

        svg.attr("transform", "translate(" 
            + transform.x + "," + transform.y 
            + ")scale(" + transform.k + ")"); 
    } 
</script> 

此配方生成以下缩放和滚动效果:

如何操作...

原始

上一张图像显示了可视化的原始状态,而下一张图像显示了当用户通过在桌面上的鼠标滚轮滚动或使用触摸屏设备的多手势放大时发生了什么。

如何操作...

放大

以下截图显示了当用户用鼠标或手指拖动(滚动)图像时发生了什么。

如何操作...

滚动

工作原理...

在这个阶段,你可能会惊讶地看到实现这个完全功能的缩放和拖动效果所需的代码是多么少。如果你在浏览器中打开了这份食谱,你也会注意到缩放和拖动对鼠标滚轮和多指触摸手势都反应得非常好。大部分的重活都是由 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", zoomHandler) // <-D 
            ) 
            .append("g"); 

如我们在行 A 中看到的,创建了一个 d3.zoom 函数(参考行 B),并在 svg 容器上调用它。d3.zoom 将自动创建事件监听器来处理关联的 SVG 容器(在我们的例子中,是 svg 元素本身)上的低级缩放和拖动手势。低级缩放手势随后将被转换为高级 D3 缩放事件。默认的事件监听器支持鼠标和触摸事件。在行 C 中,我们使用一个包含两个元素 [1, 10](一个范围)的数组定义了 scaleExtent。缩放范围定义了允许缩放的程度(在我们的例子中,我们允许 10 倍缩放)。最后,在行 D 中,我们注册了一个自定义的缩放事件处理器来处理 D3 缩放事件。现在,让我们看看这个缩放事件处理器执行了什么任务:

function zoomHandler() { 
    var transform = d3.event.transform; 

    svg.attr("transform", "translate(" 
        + transform.x + "," + transform.y 
        + ")scale(" + transform.k + ")"); 
} 

zoom 函数中,我们只是将实际的缩放和拖动委托给 SVG 转换。为了进一步简化这个任务,D3 缩放事件也计算了必要的平移和缩放。所以,我们只需要将它们嵌入到 SVG 转换属性中。以下是缩放事件中包含的属性:

  • transform.xtransform.y:当前平移向量

  • transform.k:表示当前缩放比例的数字

在这个阶段,你可能想知道拥有这个 zoomHandler 函数的目的是什么。为什么 D3 不能为我们处理这一步呢?原因在于 D3 的缩放行为并不是专门为 SVG 设计的,而是作为一个通用的缩放行为支持机制设计的。因此,这个缩放函数实现了将通用的缩放和拖动事件转换为 SVG 特定的转换。

更多...

缩放函数还能够执行除了简单的坐标系统转换之外的其他任务。例如,一种常见的技巧是在用户发出缩放手势时加载额外的数据,从而在缩放函数中实现钻取功能。一个著名的例子是数字地图;当你增加地图的缩放级别时,更多的数据和细节就可以被加载和展示。

参见

实现拖动行为

在本章中,我们将探讨的另一个常见交互式可视化行为是拖动。拖动对于提供可视化中的图形重新排列或甚至通过力量提供用户输入的能力非常有用;我们将在下一章中讨论这一点。在此菜谱中,我们将探讨如何在 D3 中支持拖动行为。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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.drag() // <-A 
            .on("drag", move); 

    svg.selectAll("circle") 
            .data(data) 
            .enter().append("circle") 
            .attr("r", r) 
            .attr("transform", function (d) { 
                return "translate(" + d + ")"; 
            }) 
            .call(drag); // <-A 

    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 + ")"; 
                }); 
    } 

    function inBoundaries(x, y){ 
        return (x >= (0 + r) && x <= (width - r))  
            && (y >= (0 + r) && y <= (height - r)); 
    } 
</script> 

此菜谱在以下四个圆圈上生成拖动行为:

如何做到这一点...

原始

上一张图片显示了此菜谱在其原始状态下渲染的内容,而下一张图片显示了当用户将每个圆圈从中心拖动时会发生什么。

如何做到这一点...

拖动

它是如何工作的...

如我们所见,拖动支持遵循与 D3 缩放支持类似的模式。主要的拖动能力由d3.drag函数提供(参考行 A)。D3 拖动行为自动创建适当的低级事件监听器来处理给定元素上的拖动手势,然后将低级事件转换为高级 D3 拖动事件。支持鼠标和触摸事件,如下所示:

var drag = d3.behavior.drag() // <-A 
            .on("drag", move); 

在此菜谱中,我们关注的是drag事件,它由我们的move函数处理。与缩放行为类似,D3 拖动行为支持是事件驱动的,因此,它允许在实现中具有最大的灵活性,不仅支持 SVG,还支持 HTML 画布。一旦定义,该行为可以通过在给定的选择上调用它来附加到任何元素:

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.selection.call 函数和选择操作的更多详细信息,请参阅第二章 选择

  • 有关 D3 拖动支持的更多详细信息,请参阅 d3.behavior.drag API 文档,链接为 github.com/d3/d3-drag/blob/master/README.md#drag

使用力

在本章中,我们将涵盖:

  • 使用重力和电荷

  • 自定义速度

  • 设置链接约束

  • 使用力来辅助可视化

  • 操作力

  • 构建力导向图

第十一章:简介

使用力量,卢克!

师傅对徒弟的智慧之言

在这一章中,我们将涵盖 D3 最迷人的方面之一:力。力模拟是您可以添加到可视化中的一种最令人敬畏的技术。通过一系列高度交互和完全功能性的示例,我们将帮助您探索 D3 力(例如,力导向图)的典型应用,以及力操作的其他基本方面。

D3 力模拟支持并非作为一个独立的功能,而是一种额外的 D3 布局。正如我们在第九章中提到的,“布局”,D3 布局是非视觉数据导向的布局管理程序,旨在与不同的可视化一起使用。力模拟最初是为了实现一种称为力导向图的特定类型可视化而创建的。其实现使用标准的速度 Verlet 积分来模拟粒子上的物理力。

换句话说,D3 实现了一种数值方法,它能够使用步进时间函数通过其速度松散地模拟粒子的运动。当然,这种模拟在实现特定可视化(如力导向图)方面是理想的;然而,您也会在本章的菜谱中发现,力模拟能够生成许多其他有趣的视觉效果,这得益于其在自定义力操作方面的灵活性。本章中介绍的技术应用甚至超出了数据可视化领域,并在许多其他领域有实际应用,例如用户界面设计。当然,在本章中,我们还将涵盖力的经典应用:力导向图。

使用重力和电荷

在这个菜谱中,我们将向您介绍前两种基本力:重力和电荷。正如我们之前提到的,力布局设计的一个目标是对粒子的运动进行松散模拟,而这个模拟的一个主要特点是电荷力。此外,力模拟还实现了伪重力,或者更准确地说,是一种通常以画布为中心的弱几何约束,可以利用它来防止您的可视化逃离画布。在下面的示例中,您将了解如何利用这两种基本、有时相反的力,通过粒子系统生成各种效果。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter11/gravity-and-charge.html .

如何做...

在以下示例中,我们将实验不同的力模拟重力电荷设置,以便您更好地理解涉及的不同对立力及其相互作用:

<script type="text/javascript"> 
    var w = 1280, h = 800, r = 4.5, 
        nodes = [], 
        force = d3.forceSimulation() 
                .velocityDecay(0.8) 
                .alphaDecay(0) 
                .force("collision",  
                   d3.forceCollide(r + 0.5).strength(1)); 

    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", r) 
        .transition() 
            .delay(7000) 
            .attr("r", 1e-6) 
            .on("end", function () { 
                nodes.shift(); // <-B 
                force.nodes(nodes); 
            }) 
            .remove(); 

        nodes.push(node); // <-C 
        force.nodes(nodes); 
    }); 

    function noForce(){ 
        force.force("charge", null); 
        force.force("x", null); 
        force.force("y", null); 
        force.restart(); 
    } 

    function repulsion(){ 
        force.force("charge", d3.forceManyBody().strength(-10)); 
        force.force("x", null); 
        force.force("y", null); 
        force.restart(); 
    } 

    function gravity(){ 
        force.force("charge", d3.forceManyBody().strength(1)); 
        force.force("x", null); 
        force.force("y", null); 
        force.restart(); 
    } 

    function positioningWithGravity(){ 
        force.force("charge", d3.forceManyBody().strength(0.5)); 
        force.force("x", d3.forceX(w / 2)); 
        force.force("y", d3.forceY(h / 2)); 
        force.restart(); 
    } 

    function positioningWithRepulsion(){ 
        force.force("charge", d3.forceManyBody().strength(-20)); 
        force.force("x", d3.forceX(w / 2)); 
        force.force("y", d3.forceY(h / 2)); 
        force.restart(); 
    } 

</script> 

<div class="control-group"> 
    <button onclick="noForce()"> 
        No Force 
    </button> 
    <button onclick="repulsion()"> 
        Repulsion 
    </button> 
    <button onclick="gravity()"> 
        Gravity 
    </button> 
    <button onclick="positioningWithGravity()"> 
        Positioning with Gravity 
    </button> 
    <button onclick="positioningWithRepulsion()"> 
        Positioning with Repulsion 
    </button> 
</div> 

此配方生成一个具有力的粒子系统,能够在以下图中显示的模式中运行:

如何做...

力模拟模式

它是如何工作的...

在我们动手编写前面的代码示例之前,让我们先深入探讨一下α衰变、速度衰减、电荷、定位和碰撞的基本概念,这样我们可以更容易地理解在这个配方中我们将使用的所有神奇数字设置。

α衰减

α值决定了模拟的“热度”。默认情况下,模拟从α值为 1 开始,通过 300 次迭代衰减到 0。因此,如果您将α衰减设置为 0,表示没有衰减,因此模拟将永远不会停止。我们将在这个章节中使用这个设置,以便更好地展示效果。在实际的视觉化中,您通常会使用某种程度的衰减,这样模拟在一段时间后会冷却下来,类似于现实世界中粒子的行为。

速度衰减

在模拟粒子的每个 tick 中,速度会按指定的衰减值缩小。因此,值为 1 对应于无摩擦环境,而值为 0 则会使所有粒子立即冻结,因为它们失去了速度。

电荷

电荷被指定来模拟粒子之间的相互 n 体力。负值将导致相互节点排斥,而正值将导致相互节点吸引。

定位

如果指定了 X 或 Y 定位力,模拟将根据配置的强度将粒子推向给定维度上的期望位置。这通常用作作用于模拟中所有粒子的全局力。

碰撞

碰撞力将粒子视为具有一定半径的圆而不是无大小的点。这将防止粒子在模拟中重叠。

好的,现在定义已经明确了,让我们看看这些力如何被利用来生成有趣的视觉效果。

设置零力布局

要设置零力布局,我们只需设置既没有重力也没有电荷的力布局。力布局可以使用d3.forceSimulation函数创建:

var w = 1280, h = 800, r = 4.5, 
        nodes = [], 
        force = d3.forceSimulation() 
                .velocityDecay(0.8) 
                .alphaDecay(0) 
                .force("collision",  
                    d3.forceCollide(r + 0.5).strength(1)); 

首先,我们禁用 alphaDecay 以确保在设置 velocityDecay0.8 以模拟摩擦效果的同时,模拟可以继续运行而不冷却。接下来,我们将 collision 设置为稍大于我们稍后创建的 svg:circle 元素的半径。有了这个设置,我们就可以在用户移动鼠标时在 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", r) 
        .transition() 
            .delay(7000) 
            .attr("r", 1e-6) 
            .on("end", function () { 
                nodes.shift(); // <-B 
                force.nodes(nodes); 
            }) 
            .remove(); 

        nodes.push(node); // <-C 
        force.nodes(nodes); 
    }); 

节点对象最初在行 A 上创建,其坐标设置为当前鼠标位置。像所有其他 D3 布局一样,力模拟不知道也没有视觉元素。因此,我们创建的每个节点都需要在行 C 上添加到布局的节点数组中,并在行 B 上移除这些节点的视觉表示。默认情况下,力模拟在创建模拟后立即自动启动。在没有重力和电荷的情况下,这个设置实际上允许我们通过鼠标移动放置一串节点,如下面的截图所示:

设置零力布局

无重力或电荷

设置相互排斥

在下一个模式中,我们将电荷设置为负值,而不应用任何全局定位力,以便生成相互排斥的力场:

    function repulsion(){ 
        force.force("charge", d3.forceManyBody().strength(-10)); 
        force.force("x", null); 
        force.force("y", null); 
        force.restart(); 
    } 

这些行告诉力布局对每个节点应用 -10 电荷,并根据每个时间步的模拟结果相应地更新节点的 {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 事件监听器函数,该函数根据力布局的计算更新所有圆元素到其新位置。时间步监听器在模拟的每个时间步触发。在每个时间步,我们将 cxcy 属性设置为 d 上的 xy 值。这是因为我们已将节点对象作为数据绑定到这些圆元素上。因此,它们已经包含了力布局计算出的新坐标。这有效地建立了力布局对所有粒子的控制。

注意

力模拟还在节点对象上设置了除 x 和 y 之外的其他值,我们将在后面的菜谱中介绍并利用这些值来实现力拖动和自定义力。在这个菜谱中,让我们先专注于基于简单力的定位。

除了 tick 之外,力布局还支持一些其他事件:

  • tick: 在模拟的每个时间步触发

  • end: 当模拟结束时触发

这种力设置生成了以下视觉效果:

设置相互排斥

相互排斥

设置重力

当我们将电荷更改为正值时,它会在粒子之间产生相互吸引或重力:

     function gravity(){ 
        force.force("charge", d3.forceManyBody().strength(1)); 
        force.force("x", null); 
        force.force("y", null); 
        force.restart(); 
    } 

这生成了以下视觉效果:

设置重力

重力

使用重力设置定位

当我们打开中心定位力并启用重力时,它会产生与相互吸引类似的效果;然而,当鼠标光标从中心移开时,你可以注意到强烈的引力:

    function positioningWithGravity(){ 
        force.force("charge", d3.forceManyBody().strength(0.5)); 
        force.force("x", d3.forceX(w / 2)); 
        force.force("y", d3.forceY(h / 2)); 
        force.restart(); 
    } 

此菜谱生成以下效果:

设置带有重力的定位

带有重力的定位

使用排斥力设置定位

最后,我们可以同时打开定位和相互排斥。结果是保持所有粒子相对稳定的力平衡,既不会逃逸也不会相互碰撞:

    function positioningWithRepulsion(){ 
        force.force("charge", d3.forceManyBody().strength(-20)); 
        force.force("x", d3.forceX(w / 2)); 
        force.force("y", d3.forceY(h / 2)); 
        force.restart(); 
    } 

这是这种力平衡的外观:

设置带有排斥力的定位

带有排斥力的定位

参考信息

自定义速度

在我们之前的菜谱中,我们提到了力模拟节点对象及其{x, y}属性,这些属性决定了节点在布局中的位置。在这个菜谱中,我们将讨论物理运动模拟的另一个有趣方面:速度。D3 力布局内置了对速度模拟的支持,这依赖于节点对象上的{vx, vy}属性。让我们看看在这个菜谱中描述的示例中如何实现这一点。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter11/velocity.html .

如何操作...

在这个菜谱中,我们将通过首先禁用定位和电荷,然后给新添加的节点一些初始速度来修改先前的菜谱。结果,现在,鼠标移动得越快,每个节点的初始速度和动量就越大。以下是实现这一点的代码:

<script type="text/javascript"> 
    var r = 4.5, nodes = []; 

    var force = d3.forceSimulation() 
                    .velocityDecay(0.1) 
                    .alphaDecay(0) 
                    .force("collision",  
                         d3.forceCollide(r + 0.5).strength(1)); 

    var svg = d3.select("body").append("svg:svg"); 

    force.on("tick", function () { 
        svg.selectAll("circle") 
                .attr("cx", function (d) {return d.x;}) 
                .attr("cy", function (d) {return d.y;}); 
    }); 

    var previousPoint; 

    svg.on("mousemove", function () { 
        var point = d3.mouse(this), 
            node = { 
                x: point[0], 
                y: point[1], 
                vx: previousPoint? 
                     point[0]-previousPoint[0]:point[0], 
                vy: previousPoint? 
                     point[1]-previousPoint[1]:point[1] 
            }; 

        previousPoint = point; 

        svg.append("svg: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", r) 
            .transition() 
            .delay(5000) 
                .attr("r", 1e-6) 
                .on("end", function () { 
                    nodes.shift(); 
                    force.nodes(nodes); 
                }) 
                .remove(); 

        nodes.push(node); 
        force.nodes(nodes); 
    }); 
</script>  

这个配方生成一个粒子系统,其初始方向速度与用户的鼠标移动成比例,如下面的截图所示:

如何做...

速度

它是如何工作的...

这个配方的整体结构与之前的配方非常相似。它也会在用户移动鼠标时生成粒子。此外,一旦力模拟开始,粒子位置将完全由力布局在其 tick 事件监听器函数中控制。然而,在这个配方中,我们关闭了定位和电荷,以便我们可以更清楚地关注动量。我们留下了一些摩擦,使得速度衰减,使模拟看起来更真实。以下是我们的力布局配置:

var force = d3.forceSimulation() 
                    .velocityDecay(0.1) 
                    .alphaDecay(0) 
                    .force("collision",  
                         d3.forceCollide(r + 0.5).strength(1)); 

在这个配方中,与之前的不同之处在于我们不仅跟踪当前鼠标位置,还跟踪之前的鼠标位置。此外,每当用户移动鼠标时,我们都会生成一个包含当前位置(point[0], point[1])和之前位置(previousPoint.x, previousPoint.y)的节点对象:

    var previousPoint; 

    svg.on("mousemove", function () { 
        var point = d3.mouse(this), 
            node = { 
                x: point[0], 
                y: point[1], 
                vx: previousPoint? 
                    point[0]-previousPoint[0]:point[0], 
                vy: previousPoint? 
                    point[1]-previousPoint[1]:point[1] 
            }; 

        previousPoint = point; 
    ... 
} 

由于用户的鼠标位置以固定间隔采样,用户移动鼠标的速度越快,这两个位置之间的距离就越远。这种属性以及从这两个位置获得的方向信息被力模拟自动转换为每个我们创建的粒子的初始速度,正如我们在本配方中展示的那样。

除了我们之前讨论的 {x, y, vx, vy} 属性之外,力布局节点对象还支持一些其他有用的属性,我们将在此列出供您参考:

  • index: 节点在其数组中的零基索引

  • x: 当前节点位置的 x-坐标

  • y: 当前节点位置的 y-坐标

  • vx: 节点的当前 x-速度

  • vy: 节点的当前 y-速度

  • fx: 节点的固定 x-位置

  • fy: 节点的固定 y-位置

注意

我们将在涉及拖动的后续配方中介绍 fxfy 及其用法,拖动是利用节点固定定位的最常见方式之一。

参见

  • 有关如何在 D3 中与鼠标交互的更多详细信息,请参阅第十章“与您的可视化交互”中的“与鼠标事件交互”配方,链接为 Chapter 10。

  • 有关节点属性的更多详细信息,请参阅 D3 力模拟节点 API,链接为 github.com/d3/d3-force#simulation_nodes

设置链接约束

到目前为止,我们已经涵盖了力布局的一些重要方面,例如重力、电荷、摩擦和速度。在这个配方中,我们将讨论另一个关键功能:链接。正如我们在介绍部分中提到的,D3 力模拟支持可扩展的简单图约束,在这个配方中,我们将演示如何结合其他力利用链接约束。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter11/link-constraint.html .

如何操作...

在此配方中,每当用户点击鼠标时,我们将生成一个由节点间的链接约束的力导向粒子环。以下是它的实现方式:

<script type="text/javascript"> 
    var w = 1280, h = 800, 
            r = 4.5, nodes = [], links = []; 

    var force = d3.forceSimulation() 
                    .velocityDecay(0.8) 
                    .alphaDecay(0) 
                    .force("charge",  
                        d3.forceManyBody() 
                            .strength(-50).distanceMax(h / 4)) 
                    .force("collision",  
                        d3.forceCollide(r + 0.5).strength(1)); 

    var duration = 10000; 

    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 boundX(d.x);}) 
            .attr("cy", function (d) {return boundY(d.y);}); 

        svg.selectAll("line") 
            .attr("x1", function (d) {return boundX(d.source.x);}) 
            .attr("y1", function (d) {return boundY(d.source.y);}) 
            .attr("x2", function (d) {return boundX(d.target.x);}) 
            .attr("y2", function (d) {return boundY(d.target.y);} 
        ); 
    }); 

    function boundX(x) { 
        return x > (w - r) ? (w - r): (x > r ? x : r); 
    } 

    function boundY(y){ 
        return y > (h - r) ? (h - r) : (y > r ? y : r); 
    } 

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

    function createNodes(point) { 
        var numberOfNodes = Math.round(Math.random() * 10); 
        var newNodes = []; 

        for (var i = 0; i < numberOfNodes; ++i) { 
            newNodes.push({ 
                x: point[0] + offset(), 
                y: point[1] + offset() 
            }); 
        } 

        newNodes.forEach(function(e){nodes.push(e)}); 

        return newNodes; 
    } 

    function createLinks(nodes) { 
        var newLinks = []; 
        for (var i = 0; i < nodes.length; ++i) { // <-A 
            if(i == nodes.length - 1) 
                newLinks.push( 
                    {source: nodes[i], target: nodes[0]} 
                ); 
            else 
                newLinks.push( 
                    {source: nodes[i], target: nodes[i + 1]} 
                ); 
        } 

        newLinks.forEach(function(e){links.push(e)}); 

        return newLinks; 
    } 

    svg.on("click", function () { 
        var point = d3.mouse(this), 
                newNodes = createNodes(point), 
                newLinks = createLinks(newNodes); 

        newNodes.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(d3.drag() // <-D 
                            .on("start", dragStarted) 
                            .on("drag", dragged) 
                            .on("end", dragEnded)) 
                    .transition() 
                .attr("r", 7) 
                    .transition() 
                    .delay(duration) 
                .attr("r", 1e-6) 
                .on("end", function () {nodes.shift();}) 
                .remove(); 
        }); 

        newLinks.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) 
                .on("end", function () {links.shift();}) 
                .remove(); 
        }); 

        force.nodes(nodes); 
        force.force("link",  
                         d3.forceLink(links) 
                            .strength(1).distance(20)); // <-C 
        force.restart(); 
    }); 

    function dragStarted(d) { 
        d.fx = d.x; // <-E 
        d.fy = d.y; 
    } 

    function dragged(d) { 
        d.fx = d3.event.x; // <-F 
        d.fy = d3.event.y; 
    } 

    function dragEnded(d) { 
        d.fx = null; // <-G 
        d.fy = null; 
    } 
</script> 

此配方在鼠标点击时生成力导向的粒子环,如下面的截图所示:

如何操作...

力导向粒子环

它是如何工作的...

链接约束为力辅助可视化添加了另一个有用的维度。在此配方中,我们使用以下参数设置我们的力布局:

var force = d3.forceSimulation() 
                    .velocityDecay(0.8) 
                    .alphaDecay(0) 
                    .force("charge", d3.forceManyBody() 
                             .strength(-50).distanceMax(h / 4)) 
                    .force("collision",   
                            d3.forceCollide(r + 0.5).strength(1)); 

除了碰撞、电荷和摩擦之外,这次我们还把电荷-力相互作用绑定到最大高度的 25%,以模拟更局部的力相互作用。当用户点击鼠标时,会创建一定数量的节点并将其置于力模拟的控制之下,这与我们在之前的配方中所做的一样。此配方的主要新增功能是链接创建,其控制逻辑如下面的代码片段所示:

    function createLinks(nodes) { 
        var newLinks = []; 
        for (var i = 0; i < nodes.length; ++i) { // <-A 
            if(i == nodes.length - 1) 
                newLinks.push( 
                    {source: nodes[i], target: nodes[0]} 
                ); 
            else 
                newLinks.push( 
                    {source: nodes[i], target: nodes[i + 1]} 
                ); 
        } 

        newLinks.forEach(function(e){links.push(e)}); 

        return newLinks; 
    } 

    svg.on("click", function () { 
        var point = d3.mouse(this), 
                newNodes = createNodes(point), 
                newLinks = createLinks(newNodes); 

        newNodes.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(d3.drag() // <-D 
                            .on("start", dragStarted) 
                            .on("drag", dragged) 
                            .on("end", dragEnded)) 
                    .transition() 
                .attr("r", 7) 
                    .transition() 
                    .delay(duration) 
                .attr("r", 1e-6) 
                .on("end", function () {nodes.shift();}) 
                .remove(); 
        }); 

        newLinks.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) 
                .on("end", function () {links.shift();}) 
                .remove(); 
        }); 

        force.nodes(nodes); 
        force.force("link", 
                        d3.forceLink(links) 
                           .strength(1).distance(20)); // <-C 
        force.restart(); 
    }); 

createLinks函数中,创建了n-1个链接对象,将一组节点连接成一个环(在行A上的循环)。每个链接对象必须指定两个属性,即sourcetarget,告诉力布局哪些节点对通过此链接对象连接。一旦创建,我们决定在此配方中使用svg:line元素来可视化链接(参考行B)。然而,我们将在下一个配方中看到,这并不总是必须的。事实上,你可以使用几乎所有东西来可视化(包括隐藏它们,但保留链接以进行布局计算),只要这对你的可视化观众有意义。之后,我们还需要将链接对象添加到力布局的链接数组中(在行C),这样它们就可以置于力布局的控制之下。d3.forceLink函数有两个重要的参数:链接距离和链接强度;这两个参数都是与链接相关的:

  • linkDistance:这可以是一个常量或一个函数,默认为20像素。链接距离在模拟初始化时评估,并且作为弱几何约束实现。对于布局的每个 tick,计算每对链接节点的距离并将其与目标距离进行比较。然后,链接会相互移动或远离。

  • linkStength:这可以是一个常量或一个函数,默认为1。链接强度通过一个在[0, 1]范围内的值设置链接的强度(刚性)。链接强度在初始化或重置时也会被评估。

最后,我们需要将力布局生成的定位数据翻译成tick函数中每个链接的 SVG 实现,类似于我们为节点所做的那样:

    force.on("tick", function () { 
        svg.selectAll("circle") 
            .attr("cx", function (d) {return boundX(d.x);}) 
            .attr("cy", function (d) {return boundY(d.y);}); 

        svg.selectAll("line") 
            .attr("x1", function (d) {return boundX(d.source.x);}) 
            .attr("y1", function (d) {return boundY(d.source.y);}) 
            .attr("x2", function (d) {return boundX(d.target.x);}) 
            .attr("y2", function (d) {return boundY(d.target.y);}); 
    }); 

    function boundX(x) { 
        return x > (w - r) ? (w - r): (x > r ? x : r); 
    } 

    function boundY(y){ 
        return y > (h - r) ? (h - r) : (y > r ? y : r); 
    } 

如我们所见,D3 力模拟再次完成了大部分繁重的工作,因此,我们只需要在tick函数中简单地设置svg:line元素的{x1, y1}{x2, y2}。此外,我们还使用了两个有界的 X 和 Y 函数,以确保粒子和环不会逃离我们的 SVG 画布区域。为了参考,以下截图展示了力布局操作后的链接对象:

如何工作...

链接对象

在本配方中,还有一个值得提及的附加技术,即力启用拖动。本配方生成的所有节点都是“可拖动的”,当用户拖动环时,力模拟会自动重新计算所有力和约束,如图所示以下截图:

如何工作...

带力模拟的拖动

这是通过在以下代码片段中注册d3.drag事件处理程序来实现的,如图线D所示:

       newNodes.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(d3.drag() // <-D 
                            .on("start", dragStarted) 
                            .on("drag", dragged) 
                            .on("end", dragEnded)) 
                    .transition() 
                .attr("r", 7) 
                    .transition() 
                    .delay(duration) 
                .attr("r", 1e-6) 
                .on("end", function () {nodes.shift();}) 
                .remove(); 
        }); 

每个拖动事件处理程序的实现相当简单:

    function dragStarted(d) { 
        d.fx = d.x; // <-E 
        d.fy = d.y; 
    } 

    function dragged(d) { 
        d.fx = d3.event.x; // <-F 
        d.fy = d3.event.y; 
    } 

    function dragEnded(d) { 
        d.fx = null; // <-G 
        d.fy = null; 
    } 

当在特定节点上拖动时,我们使用fxfy将该节点固定到其初始位置,如图线E所示。在拖动过程中,我们继续使用用户的鼠标位置更新节点位置,从而在拖动时移动节点(参见图线F)。最后,当拖动结束时,我们解除节点位置的限制,从而允许力模拟再次接管,如图线G所示。这是一个非常通用的拖动支持模式,你将在力辅助可视化中经常看到,包括本章后面的某些配方。

参见

使用力辅助可视化

到目前为止,我们学习了如何使用力模拟可视化粒子与链接,类似于在经典应用中,即力导向图,使用力的方式。这种可视化正是力模拟最初设计的目的。然而,这绝对不是利用力进行可视化的唯一方式。在本配方中,我们将探讨我称之为力辅助可视化的技术。利用这种技术,你可以通过利用力,在你的可视化中添加一些随机性和任意性。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/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 w = 1280, h = 800, 
                r = 4.5, nodes = [], links = []; 

    var force = d3.forceSimulation() 
                    .velocityDecay(0.8) 
                    .alphaDecay(0) 
                    .force("charge", d3.forceManyBody() 
                              .strength(-50).distanceMax(h / 4)) 
                    .force("collision",  
                              d3.forceCollide(r + 0.5).strength(1)) 
                    .force("position", d3.forceY(h / 2)); 

    var duration = 60000; 

    var svg = d3.select("svg") 
                .attr("width", w) 
                .attr("height", h); 

    var line = d3.line() // <-A 
            .curve(d3.curveBasisClosed) 
            .x(function(d){return d.x;}) 
            .y(function(d){return d.y;}); 

    force.on("tick", function () { 
        svg.selectAll("path") 
            .attr("d", line); 
    }); 

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

    function createNodes(point) { 
        var numberOfNodes = Math.round(Math.random() * 10); 
        var newNodes = []; 

        for (var i = 0; i < numberOfNodes; ++i) { 
            newNodes.push({ 
                x: point[0] + offset(), 
                y: point[1] + offset() 
            }); 
        } 

        newNodes.forEach(function(e){nodes.push(e)}); 

        return newNodes; 
    } 

    function createLinks(nodes) { 
        var newLinks = []; 
        for (var i = 0; i < nodes.length; ++i) { 
            if(i == nodes.length - 1) 
                newLinks.push( 
                    {source: nodes[i], target: nodes[0]} 
                ); 
            else 
                newLinks.push( 
                    {source: nodes[i], target: nodes[i + 1]} 
                ); 
        } 

        newLinks.forEach(function(e){links.push(e)}); 

        return newLinks; 
    } 

    svg.on("click", function () { 
        var point = d3.mouse(this), 
                newNodes = createNodes(point), 
                newLinks = createLinks(newNodes); 

        console.log(point); 

        svg.append("path") 
                .data([newNodes]) 
            .attr("class", "bubble") 
            .attr("fill", "url(#gradient)") // <-B 
            .attr("d", function(d){return line(d);}) 
                .transition().delay(duration) // <-C 
            .attr("fill-opacity", 0) 
            .attr("stroke-opacity", 0) 
            .on("end", function(){d3.select(this).remove();}); 

        force.nodes(nodes); 
        force.force("link",  
                   d3.forceLink(links).strength(1).distance(20)); 
        force.restart(); 
    }); 
</script> 

此配方在用户鼠标点击时生成力辅助气泡,如下截图所示:

如何操作...

力辅助气泡

它是如何工作的...

此配方建立在之前配方的基础上,因此其整体方法与上一个配方(在用户鼠标点击时创建力控制粒子环)非常相似。此配方与上一个配方的主要区别在于,我们决定使用 d3.line 生成器来创建 svg:path 元素,以勾勒出我们的气泡,而不是使用 svg:circlesvg:line

var line = d3.line() // <-A 
            .curve(d3.curveBasisClosed) 
            .x(function(d){return d.x;}) 
            .y(function(d){return d.y;}); 
... 
svg.on("click", function () { 
        var point = d3.mouse(this), 
                newNodes = createNodes(point), 
                newLinks = createLinks(newNodes); 

        console.log(point); 

        svg.append("path") 
                .data([newNodes]) 
            .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) 
            .on("end", function(){d3.select(this).remove();}); 

        force.nodes(nodes); 
        force.force("link",  
                d3.forceLink(links).strength(1).distance(20)); 
        force.restart(); 
    }); 

在线 A 上,我们使用 d3.curveBasisClosed 曲线模式创建了一个线生成器,因为这为我们提供了气泡最平滑的轮廓。每当用户点击鼠标时,就会创建一个连接所有节点的 svg:path 元素(线 C)。此外,我们还用我们预定义的渐变填充气泡,使其具有漂亮的发光效果(线 B)。最后,我们还需要在 tick 函数中实现基于力的定位:

    force.on("tick", function () { 
        svg.selectAll("path") 
            .attr("d", line); 
    }); 

tick 函数中,我们简单地重新调用行生成器函数来更新每个路径的 d 属性,从而使用力布局计算来动画化气泡。

相关内容

  • 请参阅SVG 渐变和图案

  • 有关 D3 线生成器的更多信息,请参阅第七章中的使用线生成器配方,进入形状

力量操控

到目前为止,我们已经探索了 D3 力的许多有趣方面和应用;然而,在所有这些先前的配方中,我们只是直接将力布局的计算(重力、电荷、摩擦、碰撞和速度)应用于我们的可视化。在这个配方中,我们将更进一步,实现自定义力操控,从而创建我们自己的力类型。

在这个配方中,我们首先生成五组彩色粒子,然后为用户的触摸分配相应的颜色和分类力拉力,从而只拉动匹配颜色的粒子。由于这个配方有点复杂,我将在这里提供一个示例:如果我用我的第一根手指触摸可视化,它将生成一个蓝色圆圈并将所有蓝色粒子拉向该圆圈,而我的第二次触摸将生成一个橙色圆圈,并且只会拉动橙色粒子。这种类型的力操作通常被称为分类多焦点。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter11/multi-foci.html .

如何做到这一点...

这是您如何在代码中实现它的方法:

<script type="text/javascript"> 
    var svg = d3.select("body").append("svg"), 
            colors = d3.scaleOrdinal(d3.schemeCategory20c), 
            r = 4.5, 
            w = 1290, 
            h = 800; 

    svg.attr("width", w).attr("height", h); 

    var force = d3.forceSimulation() 
                    .velocityDecay(0.8) 
                    .alphaDecay(0) 
                    .force("charge",  
                        d3.forceManyBody().strength(-30)) 
                    .force("x", d3.forceX(w / 2)) 
                    .force("y", d3.forceY(h / 2)) 
                    .force("collision",   
                        d3.forceCollide(r + 0.5).strength(1)); 

    var 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 
            }); 
        } 
    } 

    force.nodes(nodes); 

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

    function boundX(x) { 
        return x > (w - r) ? (w - r): (x > r ? x : r); 
    } 

    function boundY(y){ 
        return y > (h - r) ? (h - r) : (y > r ? y : r); 
    } 

    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", r); 

    force.on("tick", function() { 
        var k = 0.1; 
        nodes.forEach(function(node) { 
            var center = centers[node.type]; 
            if(center){ 
                node.x += (center[0] - node.x) * k; 
                node.y += (center[1] - node.y) * k; 
            } 
        }); 

        svg.selectAll("circle") 
            .attr("cx", function (d) {return boundX(d.x);}) 
            .attr("cy", function (d) {return boundY(d.y);}); 
    }); 

    d3.select("body") 
        .on("touchstart", touch) 
        .on("touchend", touch); 

    function touch() { 
        d3.event.preventDefault(); 

        centers = d3.touches(svg.node()); 

        console.log(centers); 

        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(); 
    } 
</script> 

这个配方在触摸时生成多分类焦点,如下截图所示:

如何做到这一点...

触摸的多分类焦点

它是如何工作的...

这个配方的第一步是创建彩色粒子并在定位和排斥之间建立标准的力平衡。所有节点对象都包含单独的颜色和类型 ID 属性(行AB),因此它们可以很容易地被识别。在行C,我们让力模拟管理这些粒子的所有定位,就像我们在之前的配方中所做的那样:

var force = d3.forceSimulation() 
                    .velocityDecay(0.8) 
                    .alphaDecay(0) 
                    .force("charge",  
                        d3.forceManyBody().strength(-30)) 
                    .force("x", d3.forceX(w / 2)) 
                    .force("y", d3.forceY(h / 2)) 
                    .force("collision",  
                        d3.forceCollide(r + 0.5).strength(1)); 

    var 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 
            }); 
        } 
} 

    force.nodes(nodes); // <-C 

接下来,我们需要在用户触摸处创建一个大的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(); 
    } 

这是我们之前在第十章中看到的与多触摸设备交互配方中的标准多触摸绘图,与您的可视化交互。一旦识别出触摸点,所有自定义力的魔法都在tick函数中实现。现在,让我们看看tick函数:

    force.on("tick", function() { 
        var k = 0.1; 
        nodes.forEach(function(node) { 
            var center = centers[node.type]; // <-C 
            if(center){ 
                node.x += (center[0] - node.x) * k; // <-D 
                node.y += (center[1] - node.y) * k; // <-E 
            } 
        }); 

        svg.selectAll("circle") // <-F 
            .attr("cx", function (d) {return boundX(d.x);}) 
            .attr("cy", function (d) {return boundY(d.y);}); 
    }); 

在这个 tick 函数中,我们有熟悉的部分,在行F上,我们让力模拟控制画布上所有粒子的位置;然而,我们也引入了一个自定义力。在行C,我们遍历所有节点以识别与给定中心相关的节点,该中心代表用户的触摸。一旦我们检测到触摸中心,我们就开始逐 tick 移动粒子,使其逐渐靠近中心(行DE),使用k系数。k值越大,粒子围绕触摸点的收敛速度越快。

参考信息

  • 有关 D3 多触摸支持的信息,请参阅第十章中的与多触摸设备交互配方,与您的可视化交互

构建力导向图

最后,我们将展示如何实现力导向图,这是 D3 力的经典应用。然而,我们相信,凭借你从本章中至今所获得的所有技术和知识,实现力导向图应该感觉相当直接。

准备工作

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

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter11/force-directed-graph.html .

如何操作...

在这个菜谱中,我们将可视化 flare 数据集作为一个力导向树(树是一种特殊的图类型):

<script type="text/javascript"> 
    var w = 1280, 
            h = 800, 
            r = 4.5, 
            colors = d3.scaleOrdinal(d3.schemeCategory20c); 

    var force = d3.forceSimulation() 
            .velocityDecay(0.8) 
            .alphaDecay(0) 
            .force("charge", d3.forceManyBody()) 
            .force("x", d3.forceX(w / 2)) 
            .force("y", d3.forceY(h / 2)); 

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

    d3.json("../../data/flare.json", function (data) { 
        var root = d3.hierarchy(data); 
        var nodes = root.descendants(); 
        var links = root.links(); 

        force.nodes(nodes); 
        force.force("link",  
            d3.forceLink(links).strength(1).distance(20)); 

          var link = svg.selectAll("line") 
              .data(links) 
            .enter().insert("line") 
              .style("stroke", "#999") 
              .style("stroke-width", "1px"); 

          var nodeElements = svg.selectAll("circle.node") 
              .data(nodes) 
            .enter().append("circle") 
              .attr("r", r) 
              .style("fill", function(d) {  
                    return colors(d.parent && d.parent.data.name);  
              }) 
              .style("stroke", "#000") 
              .call(d3.drag() 
                      .on("start", dragStarted) 
                      .on("drag", dragged) 
                      .on("end", dragEnded)); 

          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; }); 

            nodeElements.attr("cx", function(d) { return d.x; }) 
                .attr("cy", function(d) { return d.y; }); 
          }); 
    }); 

    function dragStarted(d) { 
        d.fx = d.x; 
        d.fy = d.y; 
    } 

    function dragged(d) { 
        d.fx = d3.event.x; 
        d.fy = d3.event.y; 
    } 

    function dragEnded(d) { 
        d.fx = null; 
        d.fy = null; 
    } 
</script> 

这个菜谱将层次化的 flare 数据集可视化为一个力导向树:

如何操作...

力导向图(树)

它是如何工作的...

如我们已能看到的,这个菜谱相当简短,实际上四分之一的代码都致力于拖拽支持。这是因为力导向图正是力模拟最初设计的目的。因此,实际上并没有太多的事情要做,除了简单地应用正确的数据结构来应用力。首先,我们使用标准的d3.hierarchy(行A)处理层次化数据集,因为这是我们获取d3.force期望的节点和链接数据结构的方式:

d3.json("../../data/flare.json", function (data) { 
        var root = d3.hierarchy(data); // <-A 
        var nodes = root.descendants(); // <-B 
        var links = root.links(); // <-C 

        force.nodes(nodes); // <-D 
        force.force("link", // <-E 
                d3.forceLink(links).strength(1).distance(20)); 
        ... 
} 

在行B,我们利用d3.hierarchy.descendants函数检索树中包含的所有节点以及行C中节点的链接,使用d3.hierachy.links函数。这些是d3.force期望的数据结构;一旦我们有了它们,它们可以直接在行DE上传递给模拟。这个菜谱的其余部分与本章中的设置链接约束菜谱非常相似。我们创建了svg:link元素来表示链接,以及svg:circle元素来表示图中的节点:

          var link = svg.selectAll("line") 
              .data(links) 
            .enter().insert("line") 
              .style("stroke", "#999") 
              .style("stroke-width", "1px"); 

          var nodeElements = svg.selectAll("circle.node") 
              .data(nodes) 
            .enter().append("circle") 
              .attr("r", r) 
              .style("fill", function(d) { // <-F 
                  return colors(d.parent && d.parent.data.name); 
              }) 
              .style("stroke", "#000") 
              .call(d3.drag() // <-G 
                      .on("start", dragStarted) 
                      .on("drag", dragged) 
                      .on("end", dragEnded)); 

值得在这里提及的唯一部分是,我们在行F使用节点的父节点名称来着色节点,因此所有兄弟节点将被一致着色,并且在行G中,我们使用了在设置链接约束菜谱中提到的通用拖拽支持模式,以允许使用此图进行拖拽。最后,我们在 tick 函数中让力模拟完全控制节点和链接的位置:

          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; }); 

            nodeElements.attr("cx", function(d) { return d.x; }) 
                .attr("cy", function(d) { return d.y; }); 
          }); 

参见

第十二章. 了解你的地图

在本章中,我们将涵盖:

  • 投影美国地图

  • 投影世界地图

  • 构建渐变地图

简介

在许多类型的可视化中,将数据点投影并关联到地理区域的能力至关重要。地理可视化是一个复杂的话题,许多与今天网络技术相关的标准正在出现和成熟。D3 提供了几种不同的方法来可视化地理和制图数据。在本章中,我们将介绍基本的 D3 制图可视化技术,以及如何在 D3 中实现一个功能齐全的渐变地图(一种特殊用途的彩色地图)。

投影美国地图

在这个菜谱中,我们将从使用 D3 GEO API 投影美国地图开始,同时熟悉几种不同的 JSON 数据格式,用于描述地理数据。让我们首先看看地理数据在 JavaScript 中通常是如何呈现和消费的。

GeoJSON

我们将要接触的第一个标准 JavaScript 地理数据格式被称为 GeoJSON。GeoJSON 格式最初由一个开发者的互联网工作组编写和维护。后来,它由 互联网工程任务组IETF)通过 RFC 7946 标准化,并于 2016 年 8 月发布。

GeoJSON 是一种用于编码各种地理数据结构的格式。GeoJSON 支持以下几何类型:点(Point)、线字符串(LineString)、多边形(Polygon)、多点(MultiPoint)、多线字符串(MultiLineString)和多多边形(MultiPolygon)。具有附加属性的几何对象是特征对象。特征集合包含在特征集合对象中。

来源: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 文件中的几何形状是由称为弧的共享线段拼接而成的。这种技术与 Matt Bloch 的 MapShaper 和 Arc/Info 导出格式 .e00 类似。

TopoJSON Wiki github.com/topojson/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-v2/blob/master/src/chapter12/usa.html

如何做...

在这个配方中,我们将加载美国 TopoJSON 数据并使用 D3 地理 API 进行渲染。以下是代码示例:

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

    var projection = d3.geoAlbersUsa(); 

    var path = d3.geoPath() 
            .projection(projection); 

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

    var g = svg.append('g') 
            .call(d3.zoom() 
                    .scaleExtent([1, 10]) 
                    .on("zoom", zoomHandler)); 

    d3.json("../../data/us.json", function (error, us) { // <- A 
        g.insert("path") 
                .datum(topojson.feature(us, us.objects.land)) 
                .attr("class", "land") 
                .attr("d", path); 

        g.selectAll("path.state") 
                    .data(topojson.feature(us,  
                          us.objects.states).features) 
                .enter() 
                    .append("path") 
                    .attr("class", "state") 
                    .attr("d", path); 
    }); 

    function zoomHandler() { 
        var transform = d3.event.transform; 

        g.attr("transform", "translate(" 
                + transform.x + "," + transform.y 
                + ")scale(" + transform.k + ")"); 
    } 
</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.geoPath() // <- A 
            .projection(d3.geoAlbersUsa()); 
... 
g.insert("path") // <-B 
                .datum(topojson.feature(us, us.objects.land)) 
                .attr("class", "land") 
                .attr("d", path); 

        g.selectAll("path.state")                        
                 .data(topojson.feature(us, 
                      us.objects.states).features) // <-C 
                .enter() 
                    .append("path") 
                    .attr("class", "state") 
                    .attr("d", path); 

在行 A 上,我们首先创建了一个配置为 Albers USA 投影模式的 D3 GEO 路径对象。然后我们插入一个 svg:path 元素来描述美国的轮廓,因为可以通过单个 svg:path 元素(在行 B 上)实现这一点。对于每个州的轮廓,我们使用行 C 上生成的要素集合来为每个州创建一个 svg:path,这样我们就可以在悬停时突出显示该州。使用代表各州的单独 SVG 元素还可以让您响应用户交互,如点击和触摸。

就这样!这就是你使用 TopoJSON 在 D3 中投影地图所需做的所有事情。此外,我们还向父svg:g元素附加了一个缩放处理程序:

var g = svg.append('g') 
            .call(d3.zoom() 
                    .scaleExtent([1, 10]) 
                    .on("zoom", zoomHandler)); 

这允许用户对我们的地图执行简单的几何缩放。

参考以下内容

投影世界地图

如果我们的可视化项目不仅仅是关于美国,而是关注整个世界呢?不用担心,D3 提供了各种内置投影模式,这些模式与我们将在此配方中探索的世界地图配合得很好。

准备工作

在你的本地 HTTP 服务器上,使用你的网络浏览器打开以下文件的本地副本:

github.com/NickQiZhu/d3-cookbook-v2/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: 'geoAzimuthalEqualArea', fn: d3.geoAzimuthalEqualArea() 
                .scale(50) 
                .translate(translate)}, 
        {name: 'geoConicEquidistant', fn: d3.geoConicEquidistant() 
                .scale(35) 
                .translate(translate)}, 
        {name: 'geoEquirectangular', fn: d3.geoEquirectangular() 
                .scale(50) 
                .translate(translate)}, 
        {name: 'geoMercator', fn: d3.geoMercator() 
                .scale(50) 
                .translate(translate)}, 
        {name: 'geoOrthographic', fn: d3.geoOrthographic() 
                        .scale(90) 
                        .translate(translate)}, 
        {name: 'geoStereographic', fn: d3.geoStereographic() 
                                .scale(35) 
                                .translate(translate)} 
    ]; 

d3.json("../../data/world-50m.json",  
           function (error, world) { // <-B 
        projections.forEach(function (projection) { 
            var path = d3.geoPath() // <-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.geoPath生成器。我们还通过调用其projection函数自定义了地理路径生成器的投影模式。配方剩余部分几乎与之前的配方相同。使用topojson.feature函数将拓扑数据转换为地理坐标,以便d3.geoPath可以生成用于地图渲染所需的svg:path(行D)。在行E,使用了一个值得注意的新函数mesh,来自 TopoJSON。topojson.mesh函数返回表示复杂拓扑的 GeoJSON MultiLineString几何对象。这是一个渲染复杂几何形状的非常紧凑的方式,因为所有共享的弧只包含一次。在我们的情况下,由于我们实际上不需要单独可视化每个大陆的国家轮廓,并且它们共享边界,因此这是渲染中最有效的方法。

参考以下内容

构建面状图

面状图是一种专题地图,换句话说,是一种专门设计的地图,而不是通用目的的地图,它通过不同的颜色阴影或图案在地图上展示统计变量的测量值;或者有时也被称为地理热图。在前两个食谱中,我们已经看到在 D3 中的地理投影由一组svg:path元素组成,因此,它们可以被像其他svg元素一样操作,包括着色。我们将在本食谱中探索这一特性并实现一个面状图。

准备工作

在您的本地 HTTP 服务器上托管您的本地副本的以下文件,并在您的网络浏览器中打开:

github.com/NickQiZhu/d3-cookbook-v2/blob/master/src/chapter12/choropleth.html.

如何操作...

在面状图中,不同的地理区域根据它们对应的变量着色,在本例中基于 2008 年美国各县的失业率。现在,让我们看看如何在代码中实现它:

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

    var color = d3.scaleThreshold() 
            .domain([.02, .04, .06, .08, .10]) // <-A 
            .range(["#f2f0f7", "#dadaeb", "#bcbddc", 
                    "#9e9ac8", "#756bb1", "#54278f"]); 

    var projection = d3.geoAlbersUsa(); 

    var path = d3.geoPath() 
            .projection(projection); 

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

    var g = svg.append("g") 
            .call(d3.zoom() 
            .scaleExtent([1, 10]) 
            .on("zoom", zoomHandler)); 

    d3.json("../../data/us.json", function (error, us) { // <-B 
        d3.tsv("../../data/unemployment.tsv", 
                function (error, unemployment) { 
            var rateById = {}; 

            unemployment.forEach(function (d) { // <-C 
                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]); // <-D 
                    }); 

            g.append("path") 
                    .datum(topojson.mesh(us, // <-E 
                            us.objects.states, 
                             function(a, b) {  
                                 return a !== b;  
                     }))  
                    .attr("class", "states") 
                    .attr("d", path); 
        }); 
    }); 

    function zoomHandler() { 
        var transform = d3.event.transform; 

        g.attr("transform", "translate(" 
                + transform.x + "," + transform.y 
                + ")scale(" + transform.k + ")"); 
    } 
</script> 

本食谱生成了以下面状图:

如何操作...

2008 年失业率面状图

它是如何工作的...

在本食谱中,我们加载了两个不同的数据集:一个用于美国拓扑结构,另一个包含 2008 年各县的失业率(行B)。这种技术通常被认为是分层,并不一定仅限于两层。失业数据通过它们的 ID 与各县连接(行BC)。区域着色是通过在行A上定义的阈值比例尺实现的。

参考信息

第十三章。测试驱动你的可视化

在本章中,我们将涵盖:

  • 获取 Jasmine 并设置测试环境

  • 测试驱动你的可视化 - 图表创建

  • 测试驱动你的可视化 - SVG 渲染

  • 测试驱动你的可视化 - 像素级完美的条形图渲染

简介

每当我们以专业程序员的身份进行编程时,测试我们所编写的程序总是非常重要的,以确保它按设计运行并产生预期的结果。D3 数据可视化主要是由 JavaScript 程序组成的,因此就像我们编写的任何其他程序一样,数据可视化也需要进行测试,以确保它准确地表示底层数据。显然,我们可以通过视觉检查和手动测试来进行验证,这始终是构建数据可视化过程中的一个关键部分,因为视觉观察不仅给我们提供了验证正确性的机会,还可以验证美学、可用性以及许多其他有用的方面。然而,手动视觉检查可能相当主观,因此,在本章中,我们将专注于自动化单元测试。通过单元测试充分覆盖的可视化可以免除创作者手动验证正确性的劳动,同时允许创作者更多地关注美学、可用性以及其他难以自动化的重要方面。

单元测试简介

单元测试是一种方法,其中通过另一个称为测试用例的程序测试和验证程序的最小单元。单元测试背后的逻辑是,在单元级别,程序通常是更简单且更容易测试的。如果我们能够验证程序中的每个单元都是正确的,那么将这些正确的单元组合在一起将使我们更有信心,集成程序也是正确的。此外,由于单元测试通常成本低且执行速度快,一组单元测试用例可以快速频繁地执行,以提供反馈,告诉我们程序是否正在正确执行。

软件测试是一个复杂的话题,到目前为止我们只是触及了皮毛;然而,由于本章范围有限,我们现在必须停止介绍并深入到单元测试的开发中。

注意

关于软件测试中的一些重要概念,请查看以下链接:单元测试:zh.wikipedia.org/wiki/单元测试 测试驱动开发:zh.wikipedia.org/wiki/测试驱动开发 代码覆盖率:zh.wikipedia.org/wiki/代码覆盖率

获取 Jasmine 并设置测试环境

在我们开始编写单元测试用例之前,我们需要设置一个环境,以便我们的测试用例可以被执行以验证我们的实现。在这个食谱中,我们将展示如何为可视化项目设置此环境和必要的库。

准备工作

Jasmine (jasmine.github.io/) 是一个用于测试 JavaScript 代码的 行为驱动开发BDD)框架。

注意

BDD 是一种软件开发技术,它将 测试驱动开发TDD)与领域驱动设计相结合。

我们选择 Jasmine 作为我们的测试框架,因为它在 JavaScript 社区中非常受欢迎,并且它的 BDD 语法很好。您可以从 github.com/jasmine/jasmine/releases 下载 Jasmine 库。

下载后,您需要将其解压缩到 lib 文件夹中。除了 lib 文件夹外,我们还需要创建 srcspec 文件夹来存储源文件以及测试用例(在 BDD 术语中,测试用例被称为规范)。请参阅以下截图以了解文件夹结构:

准备工作

测试目录结构

如何做...

现在,我们的环境中已经有了 Jasmine,接下来要做的事情是设置一个 HTML 页面,该页面将包括 Jasmine 库以及我们的源代码和测试用例,以便它们可以被执行以验证我们的程序。这个文件在我们的设置中被称为 SpecRunner.html,它包含以下代码:

<!DOCTYPE html> 
<html> 
<head> 
  <meta charset="utf-8"> 
  <title>Jasmine Spec Runner v2.5.2</title> 

  <link rel="shortcut icon" type="image/png"  
            href="lib/jasmine-2.5.2/jasmine_favicon.png"> 
  <link rel="stylesheet" href="lib/jasmine-2.5.2/jasmine.css"> 

  <script src="img/jasmine.js"></script> 
  <script src="img/jasmine-html.js"></script> 
  <script src="img/boot.js"></script> 

  <!-- include source files here... --> 
  <script src="img/bar_chart.js"></script> 

  <!-- include spec files here... --> 
  <script src="img/spec_helper.js"></script> 
  <script src="img/bar_chart_spec.js"></script> 

</head> 

<body> 
</body> 
</html> 

它是如何工作的...

此代码遵循标准的 Jasmine 规范运行器结构,并直接在我们的 HTML 页面上生成执行报告。现在,您已经为您的可视化开发设置了一个完全功能化的测试环境。如果您用浏览器打开 SpecRunner.html 文件,您现在会看到一个空白页面;然而,如果您查看我们的代码示例,您将看到以下报告:

它是如何工作的...

Jasmine 报告

参见

测试驱动您的可视化 - 图表创建

在测试环境准备就绪后,我们可以继续开发一个简单的条形图,这与我们在第八章“Chart Them Up”中“创建条形图”食谱中所做的工作非常相似,不过这次是采用测试驱动的模式。您可以通过打开 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: 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.scaleLinear().domain([0, 3])) 
                .y(d3.scaleLinear().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(bar(0).attr('x')).toBe('0'); 
            expect(bar(1).attr('x')).toBe('20'); 
            expect(bar(2).attr('x')).toBe('40'); 
         }); 

         it('should map bar y using y-scale', function () { 
             expect(bar(0).attr('y')).toBe('60'); 
             expect(bar(1).attr('y')).toBe('30'); 
             expect(bar(2).attr('y')).toBe('0'); 
          }); 

          it('should calculate bar height based on y', function () { 
              expect(bar(0).attr('height')).toBe('10'); 
              expect(bar(1).attr('height')).toBe('40'); 
              expect(bar(2).attr('height')).toBe('70'); 
           }); 
       }); 
    }); 

    function svg() { 
        return div.select('svg'); 
    } 

    function chartBody() { 
        return svg().select('g.body'); 
    } 

    function bars() { 
        return chartBody().selectAll('rect.bar'); 
    } 

    function bar(index) { 
        return d3.select(bars().nodes()[index]); 
    } 
}); 

它是如何工作的...

在先前的测试套件中,我们描述了对图表主体svg:g元素正确转换和正确数量的条形图以及适当属性(widthxyheight)设置的期望。实际上,实现将比我们的测试用例短得多,这在经过良好测试的实现中很常见:

... 
var _parent = p, _width = 500, _height = 350, 
        _margins = {top: 10, left: 30, right: 10, bottom: 30}, 
        _data, 
        _x = d3.scaleLinear(), 
        _y = d3.scaleLinear(); 

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 之上,两者都是简单的标记语言,可以轻松验证。精心设计的测试套件可以确保你的可视化是像素级精确的,甚至是亚像素级精确的。

参见

附录章节。在几分钟内构建交互式分析

在本附录中,我们将介绍:

  • 学习 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

在这个样本数据集中,我们看到了多少个维度?答案是:它有与你可以对数据进行分类的不同方式一样多的维度。例如,由于这些数据是关于客户支付,这是时间序列的观察,显然 date 是一个维度。其次,支付类型是自然地对数据进行分类的方式;因此,type 也是一个维度。

下一个维度有点棘手,因为从技术上讲,我们可以将数据集中的任何字段建模为维度或其衍生物;然而,如果我们认为它不能帮助我们更有效地切片数据或提供更多对数据试图表达的内容的洞察,我们就不想将其作为维度。总计和小费字段具有非常高的基数,这通常是一个维度较差的指标,除非我们将它们分组到不同的桶中(尽管小费/总计,即小费百分比可能是一个有趣的维度);然而,假设人们不会在这个酒吧购买成千上万杯饮料,quantity 字段可能具有相对较小的基数,因此我们选择将数量作为第三个维度。现在,这是维度逻辑模型的外观:

crossfilter.js 库

维度数据集

这些维度使我们能够从不同的角度审视数据,并且如果结合起来,将允许我们提出一些相当有趣的问题,例如:

  • 使用点餐支付的客户更有可能购买大量商品吗?

  • 周五晚上客户更有可能购买大量商品吗?

  • 客户在使用点餐而非现金时更有可能给小费吗?

现在,你可以看到为什么维度数据集是一个如此强大的想法。本质上,每个维度都为你提供了一个不同的视角来查看你的数据,并且当它们结合在一起时,它们可以迅速将原始数据转化为知识。一位优秀的分析师可以快速使用这类工具来形成假设,从而从数据中获得知识。

如何操作...

现在,我们理解了为什么我们想要使用我们的数据集建立维度;让我们看看如何使用 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 函数(行 A)将使用 D3 加载的 JSON 数据集通过 Crossfilter 进行传递。一旦完成,你可以通过调用 dimension 函数并传入一个访问器函数来创建维度,该函数将检索用于定义维度的数据元素。对于 type 的情况,我们将简单地传入 function(d){return d.type;}。你还可以在维度函数中执行数据格式化或其他任务(例如,行 B 上的日期格式化)。在创建维度之后,我们可以使用维度进行分类或分组,因此 totalByHour 是一个按每小时汇总销售额的分组,而 salesByQuantity 是按数量计数的交易分组。为了更好地理解 group 的工作原理,我们将查看分组对象的外观。如果你在 transactionsByType 分组上调用 all 函数,你将得到以下对象:

工作原理...

跨过滤器分组对象

我们可以清楚地看到transactionByType组实际上是根据数据元素的类型对其进行分组,并在每个组内计算数据元素的总数,因为我们创建组时调用了reduceCount函数。

以下是我们在这个例子中使用的函数的描述:

  • crossfilter:如果指定,创建一个新的带有给定记录的 crossfilter。记录可以是任何对象或原语数组。

  • dimension:使用给定的值访问器函数创建一个新的维度。该函数必须返回自然排序的值,即与 JavaScript 的<, <=, >=, 和 >运算符正确行为的值。这通常意味着原语:布尔值、数字或字符串。

  • dimension.group:根据给定的groupValue函数创建给定维度的新的分组,该函数接受维度值作为输入并返回相应的舍入值。

  • group.all:返回所有组,按键的自然顺序升序排列。

  • group.reduceCount:一个用于计数记录的快捷函数;返回此组。

  • group.reduceSum:一个用于使用指定的值访问器函数求和记录的快捷函数。

还有更多...

我们只接触了 Crossfilter 函数的一小部分。当涉及到如何创建维度和组时,Crossfilter 提供了更多的功能;有关更多信息,请查看其 API 参考:Crossfilter API 参考

参见

在这个阶段,我们已经拥有了所有想要分析的内容。现在,让我们看看如何能在几分钟内而不是几小时或几天内完成这项工作。

维度图表 - dc.js

可视化 Crossfilter 维度和组正是dc.js被创建的原因。这个方便的 JavaScript 库是由你的谦逊作者创建的,旨在允许你轻松快速地可视化 Crossfilter 维度数据集。这个库最初是由你的谦逊作者创建的,现在由一群以 Gordon Woodhull 为首的社区贡献者维护。

注意

我们在本章中使用的 dc.js 2.0 beta 版本尚未升级到 D3 v4.x,因此你会注意到对旧 D3 v3 API 的使用和引用,这与我们在本书中迄今为止所见证的有所不同。

准备工作

打开以下文件的本地副本作为参考:

dc.js

如何做到这一点...

在这个例子中,我们将创建三个图表:

  • 一个用于可视化时间序列中交易总量的折线图

  • 一个用于可视化按支付类型交易数量的饼图

  • 展示按购买数量销售数量的柱状图

下面是代码的样子:

<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/dc-js/dc.js/blob/master/web/docs/api-latest.md 查看完整的 API 参考文档。

利用 crossfilter.jsdc.js 可以让您快速构建复杂的数据分析仪表板。以下是一个用于分析过去 20 年纳斯达克 100 指数的演示仪表板 dc-js.github.io/dc.js/

更多内容...

dc.js 纳斯达克演示

在撰写本书时,dc.js 支持以下图表类型:

  • 柱状图(可堆叠)

  • 线图(可堆叠)

  • 面积图(可堆叠)

  • 饼图

  • 气泡图

  • 组合图表

  • 彩色地图

  • 箱线图

  • 热力图

  • 折线图

  • 气泡叠加图

还有更多,请查看此页以获取支持的图表类型完整列表 dc-js.github.io/dc.js/examples/ 。有关 dc.js 库的更多信息,请查看我们的 Wiki 页面 github.com/dc-js/dc.js

参见

以下是一些其他有用的基于 D3 的可重用图表库。尽管它们不像 dc.js 那样原生支持与 Crossfilter 一起工作,但在应对一般的可视化挑战时,它们通常更加丰富和灵活:

posted @ 2025-09-26 22:08  绝不原创的飞龙  阅读(13)  评论(0)    收藏  举报