D3-js-示例-全-

D3.js 示例(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

独自学习 D3.js 可能是一项艰巨的任务。实际上,在线上存在成千上万的示例,其解释的有效性或无效性程度各不相同。

本书使用从入门到精通的示例,通过 D3.js 的基本概念,使用实用的示例,逐步构建每一章的内容,并在参考前一章的基础上进行。

我们将专注于为本书创建的示例以及在线上找到的优秀的示例,这些示例可能需要一些额外的解释。每个示例都将解释示例是如何工作的,要么是逐行解释,要么是与书中早期学习到的其他示例和概念进行比较。

本书涵盖内容

第一章,D3.js 入门,介绍了 D3.js 以及如何使用几个工具构建一个简单的应用程序。

第二章,选择和数据绑定,教你如何使用 D3.js 选择来根据数据创建 DOM 元素。

第三章,使用 SVG 创建可视化,介绍了可缩放矢量图形以及如何使用它们在 D3.js 可视化中渲染各种常用形状。

第四章,创建条形图,演示了如何根据给定数据创建条形图。

第五章,使用数据和比例,展示了如何从不同格式的外部源加载数据并将其转换为适合可视化的信息。

第六章,创建散点图和气泡图,演示了如何以使用户清晰看到模式的方式加载数据、缩放和绘制多维数据。

第七章,创建动画可视化,教你如何在 D3.js 应用程序中使用动画来展示数据随时间的变化。

第八章,添加用户交互,展示了如何使用鼠标允许用户与可视化进行交互。

第九章,使用路径创建复杂形状,展示了如何使用 D3.js 中的许多内置工具通过几个简单的语句自动生成复杂的路径。

第十章,使用布局可视化系列和层次数据,专注于创建利用 D3.js 布局对象的复杂图表。这包括不同类别的大量图表,包括堆叠、打包、聚类、基于流的、层次和放射状。

第十一章, 可视化信息网络,深入探讨了如何使用 D3.js 来可视化网络数据,例如社交网络中的数据。

第十二章, 使用 GeoJSON 和 TopoJSON 创建地图,教您如何使用两种地理数据形式:Geo 和 TopoJSON 来创建地图并突出显示其上的区域。

第十三章, 结合 D3.js 和 AngularJS,讨论了您如何使用 Angular.js 集成多个 D3.js 可视化来创建响应式可视化。

您需要这本书什么

本书使用的所有工具都可以在互联网上免费获得。您只需要一个现代网络浏览器来运行示例,并且所有代码都可以在浏览器中在线编辑和运行。具体来说,现代浏览器包括 Firefox、Chrome、Safari、Opera、IE9+、Android 和 iOS。

本书面向对象

无论您是数据和数据可视化的新手,还是经验丰富的数据科学家,或者是一名计算机图形专家,这本书都将为您提供您创建基于 Web 和交互式数据可视化的所需技能。本书假设您对编码有一定的了解,特别是使用 JavaScript 进行编码的经验。

术语约定

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

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称的显示方式如下:“现在使用选择变量,我们调用.enter()函数并将其分配给名为entering的变量。”

代码块以如下方式设置:

<div id='div1'>A</div>
<div id='div2'>B</div>
<div id='div3'>C</div>
<div id='div4'>D</div>
<script>
    var selector = d3.select('body')
                     .selectAll('div');
</script>

当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:

function render(dataToRender) {
    var selector = d3.select('body')
        .selectAll('div')
        .data(dataToRender);

 var entering = selector.enter();
    entering.append('div')
        .text(function(d) { return d; });
} 

新术语重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“Mikael的值已更改为25。”

注意

警告或重要注意事项以如下方式显示。

小贴士

小贴士和技巧看起来是这样的。

读者反馈

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

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

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

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

下载示例代码

所有本文本中的示例均可在线查看、执行和编辑。对代码的引用称为bl.ock,并按以下方式引用:

注意

bl.ock (2.13): bl.ocks.org/d3byex/35641fbe385e5a162b84

这将带您进入bl.ocks.org/上的示例页面。此页面还将包含一个链接,带您进入 jsbin.com,您可以在那里交互式地更改代码。

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

错误清单

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

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

盗版

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

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

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

询问

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

第一章:D3.js 入门

D3.js 是一个开源的 JavaScript 库,它提供了基于数据操作 HTML 文档的功能,使用 JavaScript 作为实现数据到文档映射的语言。因此,名称 D3Data Driven Documents)。许多人认为 D3.js 是一个数据可视化库。这可能正确,但 D3.js 为用户提供的不仅仅是可视化,例如:

  • 在 HTML DOM 中高效选择项目。

  • 数据与视觉元素绑定。

  • 处理数据项添加和删除的规范。

  • 动态样式化 DOM 元素的能力。

  • 定义用户与数据之间的交互模型。

  • 根据数据动态变化指定数据可视化之间的过渡。

  • D3.js 帮助您使用 HTMLSVGCSS 使数据生动起来。它关注数据,数据呈现给用户的方式,数据变化时可视化方式的改变,以及用户通过可视化与数据交互的方式。

我们即将开始一段精彩的探索之旅,通过创建丰富的 D3.js 数据可视化,并专注于基于实际案例的项目式 D3.js 学习。我们将从基本概念开始,然后通过各种创建动态数据可视化的 D3.js 示例进行学习。

在本章中,我们将简要介绍 D3.js 中的几个概念,创建一个最小的 D3.js 应用程序,并检查您可以使用来构建 D3.js 应用程序的一些工具。

具体来说,在本章中,我们将涵盖以下主题:

  • D3.js 简要概述

  • D3.js 的关键设计特性,包括选择、数据管理、交互、动画和模块

  • D3.js 开发工具简介,助您快速上手

  • 使用 D3.js 的简单 Hello World 程序

  • 使用 Google Chrome 开发者工具检查 D3.js 生成的 DOM

D3.js 简要概述

D3.js 是一个基于数据的 JavaScript 库,用于操作 DOM 对象。通过使用 D3.js 和现代浏览器(特别是那些可以显示和操作 SVG 的浏览器),您可以创建丰富的数据可视化。这些可视化不仅可视化数据,还可以包括描述,根据数据的变化向用户显示不同的内容,以及用户如何与代表数据的视觉元素进行交互。

备注

您可以在 d3js.org 获取 D3.js。

D3.js 简要概述

D3.js 与其他数据可视化框架(如 Processing (processing.org/))不同,因为它提供了一个基于数据的特定领域语言来转换 DOM,而像 Processing 这样的工具则提供了一个更底层的直接渲染模型。D3.js 允许你描述数据可视化的方式,而不是编写所有具体的细节来绘制视觉化的像素。这通过允许 D3.js 根据 SVG 和 CSS 的标准来处理数据渲染的细节,简化了可视化创建的过程。

D3.js 的一个基本概念是能够轻松地在网页文档中操作 DOM。这通常是一个复杂的问题,许多框架(如 jQuery)被创建来执行这项任务。D3.js 提供了与 jQuery 相似的功能,对于那些熟悉 jQuery 的人来说,D3.js 的许多内容都会感到熟悉。

但 D3.js 在提供类似 jQuery 等库的功能的基础上进行了扩展,以提供更声明式的 DOM 修改方式,用于根据数据结构创建视觉元素,而不是仅仅作为一个低级 DOM 操作的框架。

这很重要,因为数据可视化不仅需要简单地修改 DOM 的能力;它还应该描述当数据被修改时 DOM 应该如何改变,包括用户与代表数据的视觉元素交互时 DOM 的变化方式。

注意

我们在这本书中不会涵盖 jQuery。我们的重点将纯粹是如何使用 D3.js 提供的设施来操作 DOM。我们将使用 D3.js 构造来应用样式,而不是依赖于 CSS。所有这些都是为了展示如何使用 D3.js 的设施,而不是用其他工具隐藏任何部分。

我们将详细探讨 D3.js 中的许多概念,但让我们首先提及 D3.js 中一些值得注意的高层次想法。

选择

D3.js 中的核心操作是 选择,它是从文档中查询得到的 DOM 元素的一个过滤集合。当数据发生变化(即,它被加载或修改)时,D3.js 会根据数据变化的方式改变选择过滤器的结果。因此,视觉表示也会随之改变。

D3.js 使用 W3C 选择器 API (www.w3.org/TR/selectors-api/) 来识别 DOM 中的项目。这是一个由谓词组成的迷你语言,可以按标签、类、ID、属性、包含、相邻等 DOM 的多个方面过滤 DOM 元素。谓词也可以进行交集或并集操作,从而得到丰富且简洁的元素选择。

选择是通过 D3.js 的全局命名空间 d3 实现的,它提供了 d3.select()d3.selectAll() 函数。这些函数使用迷你语言,并分别返回与指定匹配的第一个或所有项目。使用这些选择的成果,D3.js 提供了通过称为 数据绑定 的过程,根据您的数据修改这些元素的能力。

数据和数据绑定

D3.js 中的数据是 绑定 到 DOM 元素上的。通过 绑定,D3.js 跟踪一组对象及其属性,并根据您指定的规则,根据该数据修改文档的 DOM。这种绑定是通过 D3.js 提供的各种运算符来执行的,这些运算符可以轻松地用来描述数据的视觉表示映射。在此阶段,我们将介绍数据绑定的三个阶段,并在 第二章 中更详细地探讨这个过程,选择和数据绑定

D3.js 中的绑定过程包括三个阶段:进入更新退出。当第一次使用 D3.js 进行选择时,您可以指定要绑定并需要进入的数据。您还可以指定每个阶段要执行的代码。

当数据首次加入选择时,需要在 DOM 中为每个数据项创建新的视觉元素。这是通过调用 .enter() 函数启动的进入过程来完成的。在 .enter() 函数之后指定的代码将用于指定每个和每个视觉表示的数据,D3.js 将使用此代码自动生成所需的 DOM,而不是您需要详细编写所有代码。

当应用程序修改这些绑定数据时,我们将重复执行选择。D3.js 将记录现有的视觉元素及其绑定数据,并允许我们根据数据的变化对视觉元素进行修改。

如果删除数据项,我们可以在选择中使用 D3.js 的 .exit() 函数来通知 D3.js 从显示中移除视觉元素。通常,这是通过告诉 D3.js 移除相关的 DOM 元素来完成的,但我们也可以执行动画来向用户展示视觉元素是如何变化的,而不是突然改变显示。

如果我们创建一个没有显式引用 .enter().exit() 的选择,我们是在通知 D3.js 我们想要对已经绑定到数据上的视觉元素进行可能的修改。这给了我们检查每个数据项属性并指导 D3.js 适当更改绑定视觉元素的机会。

这种将进入、更新和退出过程分离的做法,使得对视觉元素生命周期的控制非常精确。这些状态允许你在数据内部变化或通过用户交互变化时更新视觉元素。它还赋予你为三个状态中的每一个提供良好定义的过渡或动画的能力,这对于动态数据可视化至关重要,它不仅展示了数据的静态状态,还展示了数据如何通过运动变化。

交互和动画

D3.js 提供了基于数据变化或用户创建的事件(如鼠标事件)来动画化视觉元素的功能。这些操作通过将.on()函数作为选择的一部分与 DOM 事件集成来执行。

D3.js 的事件处理器与 jQuery 提供的事件处理器类似。然而,它们不仅调用一个函数,还向函数暴露绑定数据项,如果你愿意,还可以暴露数据项在集合中的索引。这使我们免于编写基于鼠标位置等查找数据项的代码,从而极大地简化了我们的代码。

此外,通过集成进入、更新和退出选择过程,我们可以在这些场景中声明式地编写场景过渡。这些过渡暴露了选择的styleattr运算符。我们对这些属性所做的任何更改都会被 D3.js 注意到,然后它将应用插值器,在给定的时间内将属性值从上一个值过渡到新值。

通过使用插值,我们可以避免在动画的每个步骤中重复编写视觉属性(如位置和颜色)值的更改代码。D3.js 会为我们自动完成所有这些操作!

此外,D3.js 自动管理动画和过渡的调度。这消除了你需要管理复杂的并发问题,并保证了每个元素对资源的独占访问,同时通过 D3.js 管理的共享计时器实现了高度优化的动画。

模块

D3.js 提供了一系列预构建的功能模块,帮助我们编写创建丰富和交互式数据可视化所需的大部分代码。这些模块在 D3.js 中根据提供给程序员的特性被分组到多个生成的类别中。

  • 形状: 形状模块为我们提供了众多预构建的视觉元素,包括但不限于线条、弧线、区域和散点图符号。通过使用 D3.js 的形状,我们可以简单地添加几何渲染到可视化中,而无需担心逐个详细绘制,像素像素地绘制。

  • 比例尺:本模块为我们提供了一种将数据值转换为浏览器内坐标的方法。它们通过提供现成的转换,使我们免于编写重复的、复杂的、常常是容易出错的代码。它们还提供了生成轴视觉的基础,再次节省了我们渲染复杂视觉的大量工作。

  • 布局:布局模块为我们提供了轻松(如果不是自动)计算可视化中元素之间视觉关系的工具。这通常是数据可视化中最复杂的一部分,D3.js 为我们提供了许多预构建的层次结构和物理布局,使我们的编程生活变得更加简单。

  • 行为:本模块提供了常见用户交互模式的实现。一个例子是选择行为,它实现了监听视觉元素上的鼠标事件,并改变项目的展示方式以表示用户已选择它。

  • 数据处理模块:D3.js 还包括各种数据处理实用工具,如 nest 和 cross 操作符,以及用于 CSV、JSON、TSV 等格式的数据解析器,以及用于日期和数字格式的数据。

我们将在各自的章节中详细讨论这些模块。

创建和共享 D3.js 可视化的工具

D3.js 应用程序可以使用许多,如果不是任何,网络开发工具来构建。工具的选择通常取决于个人开发者,因为每个平台(.Net、Node.JS、Ruby on Rails 等)都提供了自己的(以及许多第三方)工具。

本书将不会指定编辑器,而是通常会将您引导到所有代码的在线和功能示例,并留给读者在自己的开发环境中重现它们。

本书中的示例将使用 Js Bin (jsbin.com/) 和 bl.ocks.org (bl.ocks.org/) 的组合来提供,我们将使用 Google Chrome 开发者工具来检查示例中的 DOM。因此,对每个工具的简要介绍都是值得的,因为本书中的每个示例都将链接到 bl.ocks.org 上的一个示例,该示例本身将包含一个链接到 Js Bin 中的代码,以便您可以动态地与之互动。

Js Bin

Js Bin (jsbin.com/) 是一个网站,充当浏览器内简单 JavaScript 应用程序的快速创建和共享的开发工具。它提供了许多功能,包括保存和共享 HTML 和 JavaScript、编辑时的实时 UI 更新,以及将您的代码和数据推送到 GitHub 的非常酷的能力。

注意

GitHub 是一个免费的代码共享和源代码管理工具。如果您不熟悉它,请访问 www.github.com

我认为 Js Bin 提供了使用 D3.js 快速开始编码的最少摩擦方式。你只需访问网站,开始编辑 HTML、CSS 或 JavaScript,并在浏览器窗格中键入时查看结果。无需安装任何开发工具或 Web 服务器!

作为 Js Bin 的示例,以下链接将带你到我们的第一个示例,即完全用 HTML 编写的经典 Hello World 应用程序。jsbin.com/zimeqe/edit?html,output

Js Bin

注意

目前不必担心这个演示中嵌入在 HTML 中的代码。我们将在本章后面更复杂的示例中再次查看这个示例。

上一张截图显示了一个 bin,它是 HTML、CSS 和 JavaScript 的组合,存储在 Js Bin 的服务器上。Js Bin 用户界面为 HTML、CSS、JavaScript、控制台和 bin 中代码的 HTML 输出提供了多个标签页/窗格。当选择自动运行 JS时,任何代码的交互式更改都会重新生成输出。

这使得 Js Bin 非常适合交互式演示和创建 D3.js 可视化。

bl.ocks.org

bl.ocks.org (bl.ocks.org) 是一个用于放置在 GitHub 上的 D3.js 代码示例的服务,GitHub 是一个免费的开源代码和分享仓库,以实体形式存在,称为 gist。gist 只是由 GitHub 管理的可重用和可共享的代码片段之一。它们是记住和分享小型代码示例的绝佳方式。

bl.ocks.org 是由 D3.js 的原始创建者 Mike Bostock 创建的。只要 gist 本身是 D3.js 代码,它就能使用 gist 创建出色的 D3.js 可视化。网络上的许多(如果不是大多数)D3.js 示例都是以 bl.ocks.org 上的示例形式呈现的,本书将遵循这种模式。

为了演示,请打开bl.ocks.org/d3byex/ed79b9fee311091333d6,这将带你到 bl.ock.org 版本的Hello World示例。打开链接将显示以下内容。

bl.ocks.org

这个 bl.ock 遵循本书中将要使用的模式。每个示例都将位于自己的 bl.ocks.org 中,并包括标题、正在运行的 D3.js 代码、Js Bin 上实时代码的链接,以及示例中使用的 HTML 和任何数据。

在页面的最顶部,有一个你可以点击的链接,它也会带你到 GitHub 上的 gist。

bl.ocks.org

上一张截图中显示的链接将带你到gist.github.com/d3byex/ed79b9fee311091333d6上的页面。

bl.ocks.org

这段代码与 Js Bin 上的代码不同,它不是动态的,但你可以点击下载 Zip按钮,并将 gist 中的所有文件作为一个 ZIP 文件下载到你的系统中。

Google Chrome 和开发者工具

D3.js 应用可以在任何数量的工具中开发。在这本书中,我们将使用 Google Chrome 作为浏览器,并使用其内置的开发工具。您也可以使用 Firefox 或 Internet Explorer 以及它们各自的开发插件。从理论上讲,所有示例都将在这三个浏览器中运行得完全相同,但仅在 Google Chrome 中进行了测试。

您可以通过 Chrome 设置按钮访问开发者工具,或者使用键组合 option + command + I(在 Mac 上)或 Ctrl + Shift + I(在 Windows 上)。在 Windows 平台上按下 F12 按钮也会带您进入 Chrome 开发者工具。

以下截图展示了在 bl.ocks.org/mbostock/1353700Epicyclic Gearing bl.ock 上打开的 Google Chrome 开发者工具。

Google Chrome 和开发者工具

在打开开发者工具时,您将看到一个在浏览器中打开的面板,该面板显示页面内容的详细信息。

Google Chrome 和开发者工具

在这种情况下,面板在右侧打开(您可以配置其打开的位置),并显示带有页面主要 SVG 元素的页面 HTML,并突出显示该元素。在 HTML 中选择节点时,工具将突出显示网页中的该元素,并显示该元素的选择详情,在这种情况下,是样式。我们将在 第二章,选择和数据绑定 中使用这些工具来演示 D3.js 如何将数据绑定到 DOM 元素,并在后面的章节中了解这一点。

Hello World – D3.js 风格

现在,让我们通过一个示例来应用本章学到的知识,看看我们如何使用 D3.js 修改 DOM。示例将与我们在上一节中看到的相同;我们将逐步分析它以了解其功能。

以下是该应用程序的完整 HTML 代码:

<!DOCTYPE html>
<html>
  <head>
    <meta name="description" content="D3byEX 1.1>
  </head>
  <body>
    <script src="img/d3.v3.min.js" 
            charset="utf-8"></script>
    <script>
      d3.select('body')
        .append('h1')
        .text('Hello World!');
    </script>
  </body>
</html>

注意

bl.ock (1.1): goo.gl/7KkIuC

代码使用 h1 标签向文档的 body 标签中添加一个一级标题。然后,h1 标签的内容被设置为文本 Hello World。正如我们之前看到的,浏览器中的输出看起来如下截图所示:

Hello World – D3.js 风格

该应用程序有两个主要部分,我们几乎在每一个示例中都会看到这两个部分。第一部分包括对 D3.js 脚本的引用,该脚本通过以下代码放置在 <body> 标签内执行:

<script src="img/d3.v3.min.js" 
           charset="utf-8"></script>

这直接从 D3.js (d3js.org/) 网站引用了压缩的 D3.js 文件。您也可以复制此文件并将其放置在您的 Web 服务器或 Web 项目中。由于本书中的所有示例都是在线的,我们将始终使用此 URL。

注意,我们还需要指定charset="utf-8"。对于大多数 JavaScript 库来说,这通常不是必需的,但 D3.js 是 UTF-8 编码的,如果不包含这个属性可能会引起问题。所以,请确保不要忘记这个属性。

本例中的实际 D3.js 代码由以下三个函数组成,这些函数放置在文档主体内的另一个<script>标签中。

d3.select('body')
  .append('h1')
  .text('Hello World!');

让我们来看看这是如何将文本放入网页中的。

d3.select('body')

所有 D3.js 语句都将从使用d3命名空间开始。这是我们开始访问所有 D3.js 函数的根。在这行代码中,我们调用.select()函数,传递其body参数。这是告诉 D3.js 找到文档中的第一个body元素并将其返回给我们,以便对其进行其他操作。

.select()函数返回一个代表 body DOM 对象的 D3.js 对象。我们可以立即调用.append('h1')在文档的 body 中添加标题元素。

.append()函数返回另一个 D3.js 对象,但这个对象代表新的h1 DOM 元素。所以我们只需要进行一个链式调用:.text('Hello World!'),我们的代码就完成了。

以这种方式调用函数的过程在 D3.js 术语中被称为链式操作,在编程语言中通常被称为流畅 API。这种链式操作就是前面提到的迷你语言。每个链式调用的 D3.js 函数进一步指定操作,使您能够非常容易地通过链式方法调用描述您想要如何修改 DOM。

对于没有使用流畅语法经验的那些人来说,这有时会感觉有些奇怪,但一旦习惯了,我保证你会看到使用这种语法的原因。正如我们将通过所涵盖的示例看到的那样,这为我们提供了一种非常简洁的方法,可以声明性地指导 D3.js 我们想要在可视化中实现的内容。

注意

对于熟悉 jQuery 的人来说,这种语法看起来很熟悉。等效的代码可以用 JQuery 写成$('body').append('h1').text('Hello World');

但正如我们将在更复杂的示例中看到的那样,D3.js 提供的功能将给我们提供比 jQuery 更多的能力来创建数据可视化。

检查 D3.js 生成的 DOM

现在,让我们快速查看由这段代码创建的 DOM 结构,使用 Chrome 开发者工具。按照本章前面给出的说明打开开发者工具。我更喜欢将其显示在页面右侧,本书将遵循这一惯例。

检查 D3.js 生成的 DOM

由于这个示例(以及本书中的所有示例)都在 Js Bin 中托管,因此自动生成并注入到我们页面中的 HTML 内容有很多。要找到与我们的代码生成的文本对应的元素,你可以在浏览器中的资源管理器中钻取 DOM。否则,你可以在浏览器输出面板中的元素上右键单击,并选择检查元素,如以下屏幕截图所示:

检查由 D3.js 生成的 DOM

然后你可以直接跳转到开发者工具中的元素。

检查由 D3.js 生成的 DOM

在前面的屏幕截图中,我们可以直观地验证 <body> 标签中添加了一个新的 <h1> 标签,其文本正如我们所期望的那样。

摘要

在本章中,我们探讨了 D3.js 中的几个高级概念:选择、数据、交互和动画,以及模块。然后我们简要介绍了可以用来构建 D3.js 应用程序的一些工具,这些工具将在本书后续的示例中使用:Js Bin、bl.ocks.org、Google Chrome 和 Google Chrome 开发者工具。我们以一个非常简单的示例结束本章,该示例演示了如何将 D3.js 包含到你的应用程序中,并执行一个简单的选择操作,将内容插入到网页中。

在下一章中,我们将扩展选择的概念,并使用它将数据绑定到 DOM 中的视觉元素。我们将扩展 D3.js 的使用,以创建和修改 DIV 元素。在第三章 使用 SVG 创建视觉元素中,我们将通过使用 D3.js 来操作 SVG 来深入了解 D3.js 的真正威力。

第二章。选择和数据绑定

在本章中,你将学习如何使用 D3.js 根据数据选择和操作 HTML 页面的 DOM。D3.js 中的视觉渲染采用声明式方法,其中你告知 D3.js 如何可视化数据片段,而不是命令式地编程如何绘制视觉并遍历数据。这个过程在 D3.js 术语中被称为 选择数据绑定

为了展示如何使用 D3.js 创建由数据驱动的 DOM 元素,我们将通过一系列示例来演示如何创建 DIV 元素以显示各种整数值数组。我们首先将检查如何使用选择来提取现有的 DOM 元素,以及 D3.js 如何将数据关联到每个 DOM 元素。然后我们将探讨指导 D3.js 从数据中创建新 DOM 元素的方法。接下来,我们将讨论更新现有元素以及当特定数据项被移除时移除视觉元素的过程。

我们将专注于 HTML DOM 元素,并在后面的章节中介绍 SVG 的使用。具体来说,在本章中我们将探讨以下主题:

  • 使用 D3.js 选择器修改 DOM 元素

  • 使用 D3.js 选择器修改 DOM 元素的样式

  • 使用 .data() 将数据绑定到 DOM

  • 使用 .enter() 从新数据项创建 DOM 元素

  • 根据数据变化更新现有 DOM 元素

  • 使用 .exit() 在关联数据不再需要可视化时移除 DOM 元素

  • D3.js 进行数据绑定的技巧清单

D3.js 选择器

在其核心,D3.js 是关于选择,这是一个寻找和创建可视化数据的 DOM 元素的过程。在简单层面上,选择可以仅仅是一种在 DOM 中查找和操作已存在元素的手段。然而,D3.js 选择器也可以用来显式创建 DOM 中的新元素,以及根据底层数据模型的变化隐式创建和删除 DOM 元素。

在 第一章 中,我们看到了一个简单的选择示例,其中我们使用选择来制作 D3.js 版本的经典 Hello World 应用程序。现在我们将更深入地探讨选择的力量。我们将查看两个选择 DOM 元素并更改其样式的示例。

更改 DOM 元素的样式

在这个第一个示例中,我们将创建一个包含四个 div 元素的页面,每个 div 元素都有一个唯一的 ID。然后我们将使用 D3.js 找到第一个 div 标签,并更改其背景颜色。

注意

bl.ock (2.1): goo.gl/EnAQBc

文档的 body 标签包含以下代码:

<div id='div1'>A</div>
<div id='div2'>B</div>
<div id='div3'>C</div>
<div id='div4'>D</div>
<script>
  d3.select('div').style('background-color', 'lightblue')
</script>

上述代码的结果如下:

更改 DOM 元素的样式

此示例使用 d3.select() 函数,它返回 DOM 中与给定标签匹配的第一个元素——在这种情况下,是 'DIV'd3.select() 的结果是代表已识别的 DOM 元素及其数据的 D3.js 对象。

在 D3.js 中,这个概念被称为 选择器。函数 d3.select() 总是代表单个 DOM 元素,如果找不到元素,则返回 null 值。

选择器有如 .style() 这样的方法,可以用来更改底层元素的 CSS 样式属性,使用 .attr() 更改属性,以及使用 .text() 函数更改文本属性。

在这种情况下,我们使用 .style() 函数将 DIV 元素的 background-color 属性样式设置为 lightblue

更改多个项目的样式

要在 DOM 中选择多个项目,我们可以使用 d3.selectAll() 函数。结果是选择器,它可以表示匹配特定标准的多个 DOM 元素。

为了演示,我们将之前示例中的单行 D3.js 代码更改为以下内容:

d3.selectAll('div').style('background-color', 'lightblue')

因此,.selectAll() 的调用将代表文档中的每个四个 div 元素。.style() 的调用将应用于每个表示的 DOM 元素,从而产生以下输出:

更改多个项目的样式

注意

bl.ock (2.2): goo.gl/61p8Nv

这展示了使用 D3.js 进行选择的一个优点。链式函数调用将被应用于由 D3.js 选择产生的所有 DOM 元素。因此,我们不需要显式地遍历所有项目。这节省了我们大量的编码工作,并有助于减少潜在的错误。

注意

注意,默认情况下,选择器中的项目在创建 D3.js 选择器时是固定的。如果我们选择之后添加另一个 div,则现有选择器中的元素将不会添加新的 div 标签。

传递给 d3.select()d3.selectAll() 函数的参数也可以包括查询的一部分 CSS 规则。例如,要选择所有具有特定 ID 的元素,请将参数以 # 开头。以下示例仅选择那些 ID 为 div2 的 DOM 元素:

d3.selectAll('#div2').style('background-color', 'lightblue')

这将产生以下输出:

更改多个项目的样式

注意

bl.ock (2.3): goo.gl/TC4Yox

注意,此选择将返回所有具有 ID div2 的 DOM 元素,无论它们是 div 还是其他类型的 DOM 元素。此示例只有 div 标签,所以我们只会检索这些。此外,在页面上使用相同的 ID 值是不良的做法。但查询的方式是可行的。

如果我们想确保此查询只返回 div 元素,则可以使用以下查询,它将元素的类型放在井号符号之前:

d3.selectAll("div#div3").style('background-color', 'lightblue')

上述查询的结果如下:

更改多个项目的样式

注意

bl.ock (2.4): goo.gl/xVwV1O

现在我们来分析一个场景,其中我们希望为选择器中的每个 DOM 元素应用不同的样式。为此,我们可以将访问器函数传递给 .style() 而不是值。例如,以下代码将在 div 标签的背景颜色之间交替 lightbluelightgray

d3.selectAll("div")
    .style('background-color', function (d, i) {
        return (i % 2 === 0) ? "lightblue" : "lightgray";
    });

上述代码产生以下输出:

更改多个项目的样式

注意

bl.ock (2.5): goo.gl/PdohHx

访问器函数通常通过 D3.js 使用。访问器函数有两个参数,第一个参数代表 D3.js 与 DOM 元素关联的数据(我们将在本章后面回到这一点)。第二个参数代表在选择结果的 0 基数数组位置中的 DOM 元素。

注意

访问器函数的第二个参数是可选的。

选择器函数的返回值在许多情况下是另一个选择器(或相同的选择器)。这允许我们将方法调用链在一起。我们可以这样做,以便方便地为选择器表示的所有 DOM 元素设置多个样式。

例如,以下代码首先设置背景颜色,然后设置每个 DIV 的宽度为递增值:

d3.selectAll("div")
  .style('width', function(d, i) {
    return (10 + 10 * i) + "px";
  })
  .style('background-color', function (d, i) {
    return (i % 2 === 0) ? 'lightblue' : 'lightgray';
  });

上述代码的输出将如下所示:

更改多个项目的样式

注意

bl.ock (2.6): goo.gl/ukFFYL

在单个 .style() 调用中也可以设置多个样式属性,通过传递一个包含属性名称和值的哈希表。以下示例与上一个示例具有相同的结果:

d3.selectAll("div").style({
    width: function (d, i) { return (10 + 10 * i) + "px" },
    'background-color': function (d, i) {
        return (i % 2 === 0) ? 'lightblue' : 'lightgray';
    }
});

注意

bl.ock (2.7): goo.gl/17FVJs。输出图像被省略,因为它与上一个 bl.ock 相同。

D3.js 和数据绑定

上一个章节中的示例依赖于 DOM 中已经存在的元素。通常,在 D3.js 中,我们会从一个数据集开始,然后根据这些数据构建可视化。我们还想在数据发生变化时更改可视化,无论是添加更多数据项、删除一些或全部数据,还是更改现有对象的属性。

管理数据到视觉元素映射的过程通常被称为数据绑定,在 D3.js 术语中,它被称为数据连接(不要与 SQL 连接混淆)。D3.js 中的绑定是通过选择器的 .data() 函数执行的。

让我们深入探讨,并详细检查一些数据绑定示例。

数据绑定

对于 D3.js 的新手来说,数据绑定可能是最难适应的事情之一。即使是那些使用其他提供数据绑定的语言和框架的人,D3.js 绑定数据的方式也略有不同,了解它是如何做到的将节省很多时间。因此,我们将花时间详细检查它,因为它对于创建有效的 D3.js 可视化至关重要。

在 D3.js 中,我们通过以下选择器的函数使用绑定来驱动数据的可视化。

函数 目的
.data() 指定用于驱动可视化的数据
.enter() 返回一个表示将要显示的新项的选择器
.exit() 返回一个表示不再显示的项的选择器

这种使用测试函数的模式在 D3.js 代码中如此根深蒂固,以至于通常被称为进入/更新/退出模式或通用更新模式。它提供了一种强大的声明性方式来告诉 D3.js 你希望如何显示动态数据,并让 D3.js 处理渲染。

我们将在稍后回到这些关于进入/更新/退出的具体细节。现在,让我们先从本章早期的一个选择示例开始,其中我们选择了文档中的所有div对象。这将帮助我们理解选择器如何促进渲染过程的基础。

我们将使用之前示例中d3.selectAll()函数的一个轻微变体。在这里,我们将结果分配给一个名为selector的变量:

<div id='div1'>A</div>
<div id='div2'>B</div>
<div id='div3'>C</div>
<div id='div4'>D</div>
<script>
    var selector = d3.select('body')
                     .selectAll('div');
</script>

注意

bl.ock (2.8): goo.gl/etDgJV。输出没有显示,因为代码给出了与之前示例不同的视觉结果。

在这个先前的语句与之前的例子之间有两个细微的差别。第一个是我们选择了 body DOM 元素,第二个是我们对 div 标签链式调用了.selectAll()

使用这种函数链的模式,我们指示 D3.js 选择所有作为body标签子标签的div标签。这种选择函数调用的链式调用使我们能够遍历 HTML 文档以查找特定位置上的标签,并且正如我们将很快看到的,指定放置新视觉元素的位置。

为了帮助概念化选择器,我认为可以将选择器视为 D3.js 与那些元素关联的数据之间的映射集合。我发现用以下之类的图表来心理想象选择器是有用的:

数据绑定

上一张图中的橙色部分代表我们选择的结果的整体选择器。这个选择器包含四个由白色、圆形矩形表示的项目,每个div一个,我们可以将其视为从 0 到 3 编号。

注意

不要将选择器与数组混淆——这个图中的单个元素不能使用 [] 访问。

排序很重要,正如我们更新数据时将看到的,默认情况下,排序取决于在选择的点 DOM 元素在 DOM 中的顺序(在这种情况下,body 标签的子元素)。

选择器中的每个项目可以被认为由两个其他对象组成。第一个是实际由选择器识别的 DOM 元素,在前面的图中用蓝色方块表示。在这个方块内部是 DOM 元素类型(div),以及其 id 属性的值。

第二个是与该 DOM 元素关联的 D3.js 数据,用绿色方块表示。在这种情况下,D3.js 没有绑定任何数据,因此每个的数据都是 null(或图中的空)。这是因为这些 DOM 元素是在 HTML 中创建的,而不是使用 D3.js 创建的,因此没有关联的数据。

让我们改变这一点,并将一些数据绑定到这些 div 标签上。我们通过在选择函数之后立即链式调用 .data() 来做这件事。这个函数传递一个值或对象的集合,并通知 D3.js 你想要将每个数据与由后续函数调用创建的特定视觉表示关联起来。

为了演示这一点,让我们修改代码如下,将整数数组绑定到 div 标签上:

var selector = d3.select('body')
                 .selectAll('div')
                 .data([10, 20, 30, 40]);

注意

bl.ock (2.9): goo.gl/h1O1wX。输出被省略,因为与上一个示例在视觉上没有区别。

.data() 调用的链式调用结果告诉 D3.js,对于选择器中识别的每个项目,数据中相同索引处的数据应该被分配。在这个例子中,这并没有改变视觉。它只是将数据分配给每个 div 元素。

为了验证这一点,让我们使用开发者工具检查结果。如果你在浏览器中右键点击 A,然后选择检查元素,工具将打开。接下来,打开属性面板,如下面的截图所示:

数据绑定

在前面的屏幕截图中的突出显示的红色矩形表明,div 标签现在有一个 __data__ 属性,其值为 10。这就是 D3.js 通过在 DOM 元素上创建这个属性并分配数据来绑定数据到视觉的方式。如果你检查其他三个 div 标签,你会看到它们都具有这个属性和相关的值。

使用我们的选择器的视觉,我们得到以下值:

数据绑定

你现在可能会问,如果在调用 .data() 时项目的数量不等于选择器中的项目数量会发生什么?让我们看看这些场景,从选择器中 DOM 元素数量少于数据项的情况开始:

 var selector = d3.select('body')
                  .selectAll('div')
                  .data([10, 20, 30]);

注意

bl.ock (2.10): goo.gl/89NReN。输出再次被省略,因为视觉没有变化。

在运行此示例后打开开发者工具,并检查每个 div 标签的属性,你会注意到前三个有一个带有分配值的 __data__ 属性。第四个标签没有添加属性。这是因为 D3.js 遍历数据中的项目,逐个分配它们,并且忽略选择器中的任何额外 DOM 元素。

从概念上讲,选择器看起来如下:

数据绑定

现在让我们将代码修改为比 DOM 元素更多的数据项:

var selector = d3.select('body')
                 .selectAll('div')
                 .data([10, 20, 30, 40, 50]);

注意

bl.ock (2.11): goo.gl/CvuxNJ. 由于视觉没有变化,输出再次被省略。

在开发者工具中检查生成的 DOM,你可以看到仍然只有四个 div 元素,分别分配了 1040 的值。没有为额外的数据项创建新的视觉元素。

数据绑定

为什么在这种情况下没有创建视觉元素?这是因为 .data() 的调用只将数据分配给选择器中现有的视觉元素。由于 .data() 遍历它传递的项目,它会在最后一个项目处停止,并且忽略选择器中的额外 DOM 元素。

注意

在下一节中,我们将探讨如何为这些散乱的数据项添加视觉元素。

我认为还有一个情况值得探讨。到目前为止的 .data() 示例都使用了文档中预先存在的 div 标签。现在让我们尝试在没有现有 div 标签的情况下绑定一些数据项。这个代码的主体如下:

    var selector = d3.select('body')
                     .selectAll('div')
                     .data([10, 20, 30]);

注意

bl.ock (2.12): goo.gl/5gsEGe. 输出已被省略,因为没有视觉元素。

这不会创建任何 DOM 元素,因为我们没有在 .data() 之后链式调用任何函数来创建它们。然而,变量选择器是一个有效的选择器,有三个项目。在我们的视觉中,它看起来像以下图表,其中蓝色方块是空的:

数据绑定

如果你查看控制台创建的输出,你会看到这个选择器确实有一个包含三个项目的数组:

[[undefined, undefined, undefined]]

输出不一定显示数据,但它确实表明选择器由三个项目组成。我们的概念模型显示更多,但毕竟它只是一个概念模型,旨在理解和表示底层数据结构,而不是实际的数据结构。

现在让我们看看我们如何指导 D3.js 为数据项创建一些视觉元素来填充那些蓝色方块,并将它们显示在屏幕上。

使用 .enter() 指定进入元素

要使用 D3.js 创建视觉元素,我们需要在调用 .data() 方法之后调用选择器的 .enter() 方法。然后我们将其他方法调用链式连接起来,以添加一个或多个 DOM 元素,并且通常还会调用各种函数来设置这些 DOM 元素的属性。

为了说明 .enter() 的用法,让我们看看上一节中的最后一个示例,其中我们从页面的 div 标签开始,并使用 D3.js 绑定了三个整数:

var selector = d3.select('body')
    .selectAll('div')
    .data([10, 20, 30]);

现在通过选择器变量,我们调用 .enter() 函数并将其分配给一个名为 entering 的变量:

var entering = selector.enter();

entering 的值将代表选择器中需要创建的新项目。selector 没有选择任何 div 标签,并且因为我们绑定了三个项目,所以这个变量代表选择器中需要创建的三个新项目。

我们可以使用进入值并调用函数来指定每个项目的视觉渲染方式:

entering.append('div')
    .text(function(d) { return d; });

注意

bl.ock (2.13): goo.gl/HFdspR.

执行后,selector 的值包含三个项目,其中值已分配并且 DOM 元素已创建:

使用 .enter() 指定进入元素

页面上生成的输出将如下所示:

使用 .enter() 指定进入元素

检查生成的 DOM,我们看到创建了三个 div 标签:

使用 .enter() 指定进入元素

注意

我将把这个作为练习留给你,检查这些元素的属性以验证 __data__ 属性的创建和值的分配。

使用 .enter() 添加新项目

现在我们已经从数据中创建了没有现有视觉的 DOM 元素,让我们修改代码,通过按按钮添加新数据来更新数据。

在 D3.js 中,需要创建新视觉数据的称为 entering 状态的数据。在调用 .data() 之后,我们可以在同一个结果选择器上调用 .enter() 方法。此方法识别选择器中进入的项目,因此需要创建视觉元素。然后我们只需在 .enter() 的结果上链式调用方法,告诉 D3.js 每个数据项应该如何可视化。

让我们稍微修改一下代码,以展示这一过程。

注意

bl.ock (2.14): goo.gl/TuVYQu

此代码对前面的示例进行了一些修改。首先我们添加了一个按钮,可以按下。此按钮将调用名为 render() 的函数,并向它传递一个包含四个值的数组,其中前三个值相同。在末尾还有一个新的数据项:

<button onclick='render([10, 20, 30, 40])'>Take action!</button>

render 函数本身执行选择和创建新视觉元素的操作,但它使用传递给函数的值而不是硬编码的值数组。

  function render(dataToRender) {
    var selector = d3.select('body')
                     .selectAll('div')
                     .data(dataToRender);

    var entering = selector.enter();
    entering.append('div')
            .text(function(d) { return d; });
  }

当页面首次加载时,我们调用 render,告诉它在一个不同的数组中创建元素。

  render([10, 20, 30]);

加载的初始页面将包含以下内容:

使用 .enter() 添加新项目

当我们按下按钮时,我们再次调用 render,但传递四个值。这导致页面上的内容如下所示:

使用 .enter() 添加新项目

这可能看起来像是之前存在的div标签被四个新的标签替换了,但实际上发生的事情要微妙得多。当第二次调用render()时,对.selectAll('div')的调用创建了一个有三个项目的选择器,每个项目都有 DOM 元素和它们绑定的数据:

使用 .enter() 添加新项目

然后,.data([10, 20, 30, 40])被执行。D3.js 遍历这个数组,并将每个数据项的值与选择器中相同索引的项目进行比较。在这种情况下,位置 0、1 和 2 的项目具有102030的值,这些值分别等于数据中相同位置上的值。因此,D3.js 对这些项目没有任何操作。但是第四个值40在选择器中没有关联的项目。

使用 .enter() 添加新项目

因此,D3.js 将为数据 40 在选择器中创建一个新项目,然后应用创建视觉的函数,结果如下:

使用 .enter() 添加新项目

D3.js 保留了前三个项目(及其 DOM 元素)不变,并为只有40数据项添加了新的 DOM 元素。

注意

在这个例子中,有一点需要指出的是,我没有设置 ID 属性,因此概念选择器没有显示该属性。

更新值

现在我们来看一个例子,我们改变数据中几个项目的值。在这种情况下,我们不想在 DOM 中删除和插入一个新的视觉元素,而是简单地更新 DOM 中的属性以表示底层值的改变。

注意

这种更新的一个例子可能是需要更新的股票价格。

为了演示这一点,让我们快速修改之前的例子,当我们点击按钮时,我们现在将执行以下操作:

<button onclick='render([20, 30, 50])'>Take action!</button>

注意

bl.ock (2.15): goo.gl/nyUrRL

按下按钮后,我们得到以下结果:

更新值

页面上没有任何变化!难道页面不应该显示 20、30 和 50 吗?

这涉及到 D3.js 数据绑定的一些微妙之处。让我们逐步解释这个结果:

            var selector = d3.select('body')
                .selectAll('div')
                .data(dataToRender);

当页面加载时,.selectAll('div')的调用识别了三个div标签:

更新值

在此之后,对 .data() 的调用将新值绑定到选择器中的每个项目:

更新值

D3.js 已经改变了绑定的值,但是所有项目都被重用了,因此没有被标记为进入。因此,以下语句的结果是一个空的进入项目集。

            var entering = selector.enter();

因此,链式方法没有被执行,DOM 元素也没有被更新。

我们如何解决这个问题?实际上非常简单:我们需要处理进入元素和已存在元素的情况。为此,将渲染函数更改为以下内容:

function render(dataToRender) {
    var selector = d3.select('body')
        .selectAll('div')
        .data(dataToRender);

    var entering = selector.enter();

    entering.append('div')
        .text(function(d) { return d; });

    selector.text(function(d) { return d; });
}

唯一的不同之处在于我们添加了以下行:

    selector.text(function(d) { return d; });

当我们将方法链接到原始选择器时,链接的函数将应用于选择器中既不是进入也不是退出的所有项目(我们将在下一节中介绍退出)。结果是我们所期望的:

更新值

使用 .exit() 移除项目

现在我们来讨论当从绑定数据的集合中移除项目时,视觉如何变化。为了处理退出,我们只需要在.data()的结果上使用.exit()函数。.exit()的返回值是 D3.js 根据数据变化确定需要从可视化中移除的项目选择器集合。

为了演示项目的移除,我们将对之前的示例进行一些简单的修改。首先,让我们更改按钮代码,以便在点击时渲染以下数组:

<button onclick='render([5, 15])'>Take action!</button>

当我们执行这个更改后的页面时,我们得到以下结果:

使用 .exit() 移除项目

从概念上讲,我们本应期望得到一个只有 5 和 15 的页面,而不是 5、15 和 30。

这种结果的原因再次是因为 D3.js 处理数据绑定的方式。当我们调用.data()并使用更新后的数据时,D3.js 试图解决以下问题:

使用 .exit() 移除项目

由于.data()所做的只是更新选择器中每个项目的绑定值,并且由于值比选择器项目少,所以我们得到以下选择器作为结果:

使用 .exit() 移除项目

然后我们调用我们的代码来处理进入和更新状态。在这种情况下,没有进入的项目,而位置 0 和 1 的项目被安排进行更新。因此,前两个 div 标签获得新的文本值,第三个 div 在 DOM 中保持不变。

为了解决这个问题,我们只需要调用.exit(),并使用这个调用的结果从 DOM 中移除这些项目。我们可以将render()修改如下,这样我们就得到了期望的结果:

注意

bl.ock (2.16): goo.gl/IkIjGY

function render(dataToRender) {
    var selector = d3.select('body')
        .selectAll('div')
        .data(dataToRender);

    var entering = selector.enter();

    entering.append('div')
        .text(function(d) { return d; });

    selector.text(function(d) { return d; });

    var exiting = selector.exit();
    exiting.remove();
}

唯一的改变是最后两行的添加。现在当我们按下按钮时,我们得到期望的结果:

使用 .exit() 移除项目

关于一般更新模式的几点注意事项

为了结束这一章,我想强调一些关于使用 D3.js 基于数据管理视觉的要点。我相信这绝对会帮助你避免在学习 D3.js 时遇到问题。由于我从其他开发平台(在这些平台上数据绑定以不同的方式工作)过来,我确实遇到了这些问题,我想分享我所学到的见解,以节省你很多压力。这有点长,但我相信它非常有价值。

  • 可视化几乎总是基于数据,而不仅仅是显式编码。

  • 通常,D3.js 应用程序在页面加载时会对文档执行 .selectAll(),以表示数据的 DOM 元素。通常,这个选择的结果没有任何元素,因为页面刚刚加载。

  • 然后调用 .data() 将数据绑定到选择的结果选择器。

  • .data() 遍历传递给它的数据值,并确保选择器中有项目可以关联数据到视觉上。数据值的值被复制到这个项目上。DOM 元素不是通过 .data() 调用创建的。

  • 在许多应用程序中,数据会随着时间动态变化,而无需重新加载页面,无论是通过用户交互还是通过基于其他事件的代码更新数据。当这种情况发生时,你希望更新可视化。因此,你需要多次调用 .data()

  • 如果数据中的项目数量多于应用到的选择器中的项目数量,那么在选择器的末尾将创建更多的选择器项。这些项将被标记为进入状态。你可以通过选择器的 .enter() 函数访问它们。然后,你可以链式调用函数来为选择器中的每个新项目创建 DOM 元素。

  • 如果数据中的项目数量少于选择器中的项目数量,那么选择器的末尾将移除选择器项。这些项将被标记为退出状态。这些选择器项可以通过调用 .exit() 函数获得。这些 DOM 元素不会自动从 DOM 中移除,你需要调用 .remove() 来实现这一点。

  • 为了优化这个过程,D3.js 实际上只关注确保选择器中的项目数量与通过 .data() 指定的数据量相匹配。

  • 与选择器项关联的数据是通过值而不是引用。因此,.data() 方法会将数据复制到 DOM 元素的 __data__ 属性上。在随后的 .data() 调用中,不会对数据值与 __data__ 属性的值进行比较。

  • 要更新数据,你需要编写代码来链式调用生成 DOM 的方法,这些方法基于选择的结果,以及链式调用 .enter().exit() 函数的代码。

  • 如果新的数据值与已关联到选择器项的值相同,D3.js 并不关心。即使值没有改变,你仍然会重新渲染它,但会重用 DOM 元素。你需要提供自己的设施来管理在数据相同的情况下不再次设置属性,以优化浏览器重新渲染元素。

  • 如果你有一百万个数据项,然后只更改其中一个并再次调用 .data(),D3.js 将固有的迫使你遍历所有一百万个项。可能只有一组视觉元素会进行视觉更新,但你的应用程序每次都会努力遍历所有内容。然而,如果你有一百万个数据项,你可能在可视化之前应该寻找另一种总结数据的方法。

  • D3.js 优化了视觉元素的复用。其假设是一个可视化只会定期更新现有项目,并且添加或删除项目相对较少。因此,一般的更新模式将包括退出、更新和退出,而不是比较数据。

  • 通常,经验法则是,一千或两千个数据项及其相关视觉元素可以通过 D3.js 非常有效地处理。

好吧,这是一个相当长的列表。但随着我们通过这本书的进展,所有示例都将遵循这些指南。到那时,这些将变得习以为常。

摘要

在本章中,我们通过许多示例展示了如何使用 D3.js 创建数据驱动的可视化。我们从 D3.js 的选择器概念示例开始,使用它们从 DOM 中选择元素,并讨论了选择器是如何用于将数据项映射到 D3.js 创建的视觉元素。然后,我们检查了绑定新数据、更新数据和从 D3.js 可视化中删除数据的好几种场景。

在本章中,我们使用 D3.js 创建的视觉元素是纯 HTML 对象,主要是 div 标签。尽管我们改变了这些 div 标签的大小、背景颜色,并在其中包含了文本,但示例只是图形表示的一种非常基本的形式。

在下一章中,我们将通过将示例的重点转向处理 SVG、创建真实图形(而不仅仅是 HTML div 标签)以及为我们在本书后面创建的丰富可视化设置框架,开始显著增加图形内容。

第三章:使用 SVG 创建可视化

在本章中,我们将学习关于可缩放矢量图形(Scalable Vector Graphics),通常简称为SVG。SVG 是一种网络标准,用于在浏览器中创建基于矢量的图形。我们将从几个直接在浏览器中编码 SVG 的基本示例开始本章,最后检查如何使用 D3 根据数据创建 SVG 元素。

在本章中,我们将涵盖以下主题:

  • SVG、坐标和属性的简要介绍

  • 一个简单的 SVG 示例,绘制圆形

  • 使用基本形状:椭圆、矩形、线条和路径

  • CSS 与 SVG 和 D3.js 的关系

  • 使用描边、线帽和虚线

  • 基本变换:旋转、平移和缩放

  • 对 SVG 元素进行分组并统一应用变换

  • SVG 元素的透明度和分层

介绍 SVG

到目前为止,我们使用 D3 在 DOM 中创建新的 DIV 元素。虽然可以使用 D3 和 DIV 创建许多优秀的可视化,但 D3 真正的表现力在于用它来创建和操作 SVG 元素。

SVG 是一种 XML 标记语言,它被设计用来表达非常丰富的 2D 可视化。SVG 可以利用计算机的图形处理器来加速渲染,并且也针对用户交互和动画进行了优化。

SVG 不是直接操作屏幕上的像素,而是使用矢量来构建展示模型,然后代表你将其转换为像素。这使得与 HTML5 Canvas 等其他网络技术相比,可视化编码变得更加简单。

由于图像以矢量表示形式存储,因此模型的可视化可以缩放。这是因为所有视觉元素都可以轻松地缩放(无论是放大还是缩小),而不会因为缩放而产生视觉伪影。

SVG 有一个便利之处,即其语言可以直接在支持 SVG 的浏览器中的 HTML 中使用。D3 提供了对 SVG 的直接支持和操作,这感觉就像使用 D3 操作 DOM 一样。

SVG 坐标系

SVG 的坐标系以 SVG 元素的左上角为原点,即(0,0);x的值向右增加,而y的值向下增加。这在计算机图形系统中很常见,但对于习惯于以原点在左下或正中心的数学图形的人来说,有时可能会感到困惑。

SVG 坐标系

SVG 属性

SVG 虽然能够与 HTML 无缝集成,但它并不是 HTML。具体来说,属性和样式可能以不同的方式运作。一个例子是,大多数 HTML 元素都有宽度和高度元素,但并非所有 SVG 元素都使用这些属性。

关于 SVG 的第二个重要点是,元素的位置是通过属性设置的。因此,无法使用样式设置 SVG 元素的位置。此外,要更改 SVG 元素的位置,例如在动画中,需要编写设置元素定位属性的代码。

使用 SVG 绘制圆

我们通过使用 SVG 标签并在该标签内放置 SVG 元素,在 HTML 中使用 SVG。以下是一个非常简单的例子,它创建了三个圆:

<svg width="720" height="120">
    <circle cx="40" cy="20" r="10"></circle>
    <circle cx="80" cy="40" r="15"></circle>
    <circle cx="120" cy="60" r="20"></circle>
</svg>

这将在浏览器中产生以下图像:

使用 SVG 绘制圆

注意

bl.ock (3.1): goo.gl/UMCLtl

SVG 元素本身在页面上不可见,仅提供子标签的容器。在这本书中,我们将始终明确设置 SVG 标签的宽度和高度。在这个例子中,它被设置为宽度 720 像素,高度 120 像素。

在 SVG 元素内定位圆是通过指定圆的中心 xy 值来完成的。此位置相对于 SVG 元素的左上角,正 x 值从原点向右移动,正 y 值向下移动。圆的大小由 r 属性指定,该属性表示圆的半径。

这个例子没有指定这些圆的颜色,因此圆的默认颜色是黑色。大多数 SVG 元素通过使用 CSS 样式属性来指定颜色,然后设置样式的 fill 属性。

例如,以下代码给三个圆赋予了不同的颜色(红色、绿色和蓝色):

<svg width="720" height="120">
  <circle cx="40" cy="20" r="10" style="fill:red"></circle>
  <circle cx="80" cy="40" r="15" style="fill:green"></circle>
  <circle cx="120" cy="60" r="20" style="fill:blue"></circle>
</svg>

这将产生以下输出:

使用 SVG 绘制圆

注意

bl.ock (3.2): goo.gl/2k1ZIm

D3 选择与 SVG 元素的工作方式与 DOM 元素相同。作为一个快速示例,以下代码选择所选 svg 标签内的所有圆,并将它们的颜色设置为统一的 teal 颜色。

<svg width="720" height="120">
  <circle cx="40" cy="20" r="10" style="fill:red"></circle>
  <circle cx="80" cy="40" r="15" style="fill:green"></circle>
  <circle cx="120" cy="60" r="20" style="fill:blue"></circle>
</svg>
<script>
  d3.selectAll('circle').style('fill', 'teal');
</script>

这将在浏览器中产生以下输出:

使用 SVG 绘制圆

注意

bl.ock (3.3): goo.gl/bszmEf

SVG 提供的基本形状

在完成了一些初步介绍之后,现在让我们来探讨本书中我们将经常使用的各种 SVG 形状。我们已经看到了如何创建一个圆;现在让我们看看其他一些形状。

椭圆

圆是椭圆的一种特殊情况,其 xy 半径相同。椭圆可以有不同大小的半径,并且通常有不同的半径大小。椭圆在 SVG 中使用 <ellipse> 标签指定。我们仍然使用 cxcy 属性来定位椭圆,但不是使用 r 作为半径,而是使用两个属性 rxry 来指定 x 和 y 方向上的半径:

<ellipse cx="50" cy="30" rx="40" ry="20" />

椭圆

注意

bl.ock (3.4): goo.gl/05QCnG

矩形

矩形使用 <rect> 标签指定。左上角使用 xy 属性指定。widthheight 属性分别指定矩形的相应大小:

<rect x="10" y="10" width="150" height="100"></rect>

矩形

注意

bl.ock (3.5): goo.gl/b3w1Rq

使用 SVG 可以通过 <line> 标签绘制线条。一条线至少需要指定四个属性,通常使用五个。前两个属性 x1y1 指定线的起始位置。另外两个属性 x2y2 指定线的终点。最后一个属性,尽管不是必需的,是 stroke,它指定用于绘制线的颜色。通常,我们必须指定描边才能看到线。这里我们将其设置为 black

<line x1="10" y1="10" x2="100" y2="100" stroke="black" />

线条

注意

bl.ock (3.6): goo.gl/4qZejC

路径

路径是 SVG 中最强大的绘图结构之一。它们提供了一个符号概念,可以用来创建许多几何形状。路径可以是圆形和矩形等形状。路径还允许用户使用控制点创建曲线。

路径的绘制是通过指定一个属性 d 来控制的,该属性传递一个字符串,该字符串指定了将要执行的绘制命令。

路径的基本概念是你可以绘制一系列直线或曲线,然后可以选择填充形状内部的空隙。例如,以下命令创建了一个用黑色填充的三角形:

<path d="M 10 10 L 310 20 L 160 110 Z"/>

路径

注意

bl.ock (3.7): goo.gl/kCTbv7

一个路径通常从一个 M 命令开始,该命令从指定位置开始绘制,在这个例子中是 (10, 10)。下一个命令 L 310 10 从上一个点绘制到 (310, 10)。下一个命令 L 160 10 然后从 (310, 10) 绘制到 (160, 10)。最后的命令是 Z,它告诉 SVG 形状是封闭的。本质上,这通知 SVG 在命令字符串中存在一个到第一个位置的隐式线,在这个例子中是 (10, 10)。

注意

注意我们没有指定填充或描边颜色。在路径中,这些默认为黑色。

路径的迷你语言相当强大,因此也相当复杂。以下表格列出了其他一些常见的路径命令:

命令 目的
M 移动到
L 线到
H 水平线到
V 垂直线到
C 曲线到
Q 二次贝塞尔曲线到
T 平滑二次贝塞尔曲线到
A 椭圆弧
Z 关闭路径

D3 提供了多个工具来简化路径的使用,与手动使用字符串字面量指定相比,这些工具使得路径的使用更加简单。我们将在本章后面详细探讨这些工具。

文本

<text> SVG 标签允许我们在 SVG 元素内放置文本。在 SVG 中放置文本的方式与 HTML 中的方式不同。SVG 文本项使用矢量图形绘制,而不是进行光栅化。因此,SVG 中渲染的文本比使用 HTML 渲染的光栅化文本更灵活。使用 SVG 渲染的字母曲线保持平滑,而不是在应用整个图形的缩放级别时变得像素化。

文本使用 xy 属性进行定位,这些属性指定文本的基线位于 y,文本左对齐到 x,文本的底部基线(字母主要部分的底部部分,不包括下划线)位于文本左侧是定位的锚点。

如下所示,它还设置了字体家族、大小和填充颜色。实际要显示的文本设置为标签的内文本内容:

<text x="10" y="20" 
      fill="Red" font-family="arial" font-size="16">
  Content of the SVG text
</text>

这将在 SVG 元素的左上角渲染以下内容:

文本

注意

bl.ock (3.8): goo.gl/f89tZX

将 CSS 样式应用于 SVG 元素

SVG 元素可以像 HTML 元素一样进行样式化。可以使用具有 ID 和类属性的相同 CSS 来将样式应用于 SVG 元素,或者您可以直接使用 style 属性并指定 CSS 作为其内容。然而,HTML 中许多实际的样式在 SVG 中是不同的。例如,SVG 使用 fill 来填充矩形,而 HTML 会使用背景来填充代表矩形的 div 标签。

在这本书中,我们将尽量避免使用 CSS,并使用 D3.js 提供的函数显式地编写样式属性。但是,网络上的许多示例确实使用了 CSS 与 SVG 的组合,因此简要提及是有价值的。

以下示例演示了如何使用 CSS 样式化 SVG。该示例使用两种样式来设置几个矩形的填充。第一种样式将使所有矩形默认为红色。第二种样式定义了一个样式,使所有具有 ID willBeGreen 的矩形填充为绿色。然后示例创建了三个矩形:前两个使用 CSS 样式,第三个使用在 attributeset 样式属性中作为填充的 CSS 将其设置为蓝色。

注意

bl.ock (3.9): goo.gl/KAnc6j

样本中定义的样式如下:

<style>
    svg rect { fill: red; }
    svg rect#willBeGreen { fill: green; }
</style>

矩形的创建方式如下:

<rect x="10" y="10" width="50" height="50" />
<rect x="70" y="10" width="50" height="50" id="willBeGreen" />
<rect x="130" y="10" width="50" height="50" style="fill:blue" />

结果输出将如图所示:

将 CSS 样式应用于 SVG 元素

描边、端点和虚线

SVG 形状有一个名为 stroke 的属性。stroke 属性指定了勾勒 SVG 形状的线条的颜色。我们看到了线条中使用 stroke 的用法,但它可以与大多数 SVG 元素一起使用。

每当我们指定 stroke 时,我们通常也会使用 stroke-width 属性指定一个描边宽度。这会告知 SVG 关于将要渲染的轮廓的厚度(以像素为单位)。

为了演示strokestroke-width属性,以下示例重新创建了路径示例中的路径,并将笔触设置为10像素粗,使用red作为其颜色。此外,我们还设置了路径的fillblue。我们使用strokestyle属性设置了所有这些属性:

<path d="M 10 10 L 210 10 L 110 120 z"
      style="fill:blue;stroke:red;stroke-width:5" />

上述示例的结果如下所示:

笔触、帽子和虚线

注意

bl.ock (3.10): goo.gl/dMjdUX

如我们之前看到的,我们可以在线条上设置笔触。它也可以设置其stroke-width。让我们通过将我们的线条示例的线条厚度设置为20并将颜色设置为green来检查这一点:

<line x1="10" y1="10" x2="110" y2="110" 
      stroke="green" stroke-width="20" />

笔触、帽子和虚线

注意

bl.ock (3.11): goo.gl/p880dC

注意这条线实际上看起来像一个矩形。这是因为线条有一个名为stroke-linecap的属性,它描述了线条末端的形状,称为线帽。

此值的默认值为butt,它给我们提供了 90 度锐利的角落。其他可以使用的值还有squareround。以下示例演示了具有所有这些不同的stroke-linecap值的相同线条:

<line x1="10" y1="20" x2="110" y2="100"
        stroke="red" stroke-width="20" stroke-linecap="butt" />
<line x1="60" y1="20" x2="160" y2="100"
      stroke="green" stroke-width="20" stroke-linecap="square" />
<line x1="110" y1="20" x2="210" y2="100"
      stroke="blue" stroke-width="20" stroke-linecap="round" />
<path d="M 10 20 L 110 100 M 60 20 L 160 100 M 110 20 L 210 100"
        stroke="white" />

笔触、帽子和虚线

注意

bl.ock (3.12): goo.gl/Xcaz41

注意到对于这三条线中的每一条,我们都画了一条stroke-width20的线,然后在每条线内,我们使用单个路径和三个移动和线条命令画了一条白色线。白色线有助于区分线端帽对线条的影响。

首先检查红色线。其末端与白色线的末端齐平。将其与绿色线进行对比。在这条线中,线帽,尽管仍然是方形的,但延伸到白色线之外,宽度与笔触相同。蓝色线,具有圆形线帽,使用半径为stroke-width一半的半圆绘制。

默认情况下,SVG 线条是实心的,但也可以使用虚线创建,通过使用stroke-dasharray属性来指定。此属性给出一个整数值的列表,该列表指定了线段宽度的重复模式,第一个从stroke颜色开始,并交替与空空间:

<line x1="10" y1="20" x2="110" y2="120"
        stroke="red" stroke-width="5"
        stroke-dasharray="5,5" />
<line x1="60" y1="20" x2="160" y2="120"
        stroke="green" stroke-width="5"
        stroke-dasharray="10,10" />
<line x1="110" y1="20" x2="210" y2="120"
        stroke="blue" stroke-width="5"
        stroke-dasharray="20,10,5,5,5" />

笔触、帽子和虚线

注意

bl.ock (3.13): goo.gl/VyBBwy

应用 SVG 变换

SVG 中的S代表可缩放,而V代表矢量。这两个是名称中的两个重要部分。这使得我们能够在渲染 SVG 形状之前应用各种变换。

每个 SVG 形状都由一个或多个矢量表示,其中 SVG 中的矢量是坐标系中从原点到(x, y)距离的元组。例如,矩形将由四个二维矢量表示,每个角一个。

在创建图形可视化时,这种使用向量对数据进行建模的方法有几个优点。其中之一是我们可以为该形状定义一个围绕坐标系统的形状。以这种方式建模允许我们复制该形状,但可以在更大的图像中的不同位置放置它们,旋转它们,缩放它们,并执行许多其他操作,这些操作超出了本文的范围。

其次,这些变换是在渲染到屏幕上的像素之前应用于模型的。正因为如此,SVG 可以确保无论对图像应用何种缩放级别,它都不会出现像素化。

变换中的另一个重要概念是它们可以按链式和任何顺序应用。这在线性代数中是一个极其强大的概念,可以创建视觉的复合模型。

注意

变换及其顺序对 SVG 渲染结果的影响有很多。不幸的是,对这些的解释超出了本书的范围,但当我们对示例产生影响时,我们将根据特定示例来检查它们。

在本节中,以及本书中的其他示例中,我们将使用 SVG 提供的三个通用类型的变换:translaterotatescale。可以通过使用 transform 属性将变换应用于 SVG 元素。

为了演示变换,我们将查看几个示例,将每个变换应用于矩形,以了解它们如何影响矩形的最终渲染效果。

Rotate

我们将要考察的第一个变换是旋转。我们可以使用 .rotate(x) 通过指定度数来旋转 SVG 对象,其中 x 指定了旋转元素的角度。

为了演示这一点,以下示例将我们的矩形旋转 45 度。一个简单的由两条线组成的轴被渲染出来,作为平移的参考框架。这将被包含在这个代码片段中,但为了简洁起见,不包括在其他示例中:

<line x1="0" y1="150" x2="0" y2="0" stroke="black" />
<line x1="0" y1="0" x2="150" y2="0" stroke="black" />
<rect x="0" y="0 " width="100" height="100" fill="red"
      transform="rotate(45)" />

前面的代码片段给出了以下结果:

Rotate

注意

bl.ock (3.14): goo.gl/vLCeHD

这并不是我们可能期望的效果。这是因为矩形的旋转是围绕其左上角进行的。为了使其看起来是围绕中心旋转的,我们需要使用一个接受三个参数的 rotate() 的替代形式:旋转角度,然后是矩形左上角到一个代表矩形中心的点的偏移量:

<rect x="0" y="0" width="100" height="100" fill="red"
      transform="rotate(45,50,50)" />

Rotate

注意

bl.ock (3.15): goo.gl/ujF3iY

矩形现在已经围绕其中心旋转,但有几个角落被裁剪到包含 SVG 元素的边界之外。我们将在下一节讨论平移时修复这个问题。

Translate

SVG 元素可以通过使用变换在其包含元素内重新定位。变换是通过translate()函数执行的。translate()接受两个值:xy方向上的距离以及重新定位元素在父元素内的距离。

以下示例将绘制我们的矩形,并将其向右和向下平移 30 像素:

<rect x="0" y="0 " width="100" height="100" fill="red"
      transform="translate(30,30)" />

Translate

注意

bl.ock (3.16): goo.gl/jANiXU

现在,让我们回顾一下最后一个旋转示例,其中矩形的两个角被裁剪了。我们可以通过在旋转之前指定矩形的平移来修复这个问题,将其向右和向下移动 30 像素:

<rect x="0" y="0" width="100" height="100" fill="red"
        transform="translate(30,30) rotate(45,50,50)" />

Translate

注意

bl.ock (3.17): goo.gl/W6MeSc

这也演示了在单个字符串中应用多个变换。你可以以这种方式连续应用多个变换来处理复杂建模场景。

注意

关于平移变换的一个常见问题是为什么不直接更改xy属性来定位元素,而不是使用变换?

这个问题的答案可能非常复杂,并且有多个原因。首先,并不是所有的 SVG 元素都是通过xy属性定位的,例如,一个圆,它是通过其cxcy属性定位的。因此,没有一组一致的属性用于定位。因此,使用平移变换可以让我们无论元素类型如何,都能有一个统一的定位方式。

另一个原因是,在应用多个变换时,不容易(或不可能)访问xy属性。此外,通过各种变换,SVG 元素的实际位置可能不会直接与另一个坐标系中指定的像素或点匹配,该坐标系使用xy属性。

缩放

缩放对象会根据给定的百分比在xy轴上改变其视觉大小。缩放是通过scale()函数实现的。它可以均匀地应用到每个轴上,或者你也可以为每个轴指定不同的缩放值。

以下示例演示了缩放。我们将绘制两个矩形,一个叠在另一个上面。底部的矩形将是蓝色,上面的矩形是红色。然后红色将被缩放到其大小的 50%:

<rect x="0" y="0" width="100" height="100" fill="blue"/>
<rect x="0" y="0" width="100" height="100" fill="red"
      transform="scale(0.5)" />

Scale

注意

bl.ock (3.18): goo.gl/fCAhg7

分组

SVG 元素可以使用<g>标签进行分组。对组应用任何变换都会应用到组中的每个元素。这对于仅对特定组的项目应用整体变换来说很方便。

以下示例演示了将一组项目(带文本的蓝色矩形)的转换以及组变换如何影响这些项目。请注意,绿色矩形不受影响,因为它不是转换的一部分:

<g transform="translate(100,30) rotate(45 50 50)">
  <rect x="0" y="0" width="100" height="100" style="fill:blue" />
  <text x="15" y="58" fill="White" font-family="arial" 
        font-size="16">
        In the box
    </text>
</g>

组

注意

bl.ock (3.19): goo.gl/FY6q4D

注意,文本在矩形顶部的位置相对于组的左上角,而不是 SVG 元素。这对于确保文本相对于蓝色矩形正确旋转非常重要。

透明度

SVG 支持绘制透明元素。这可以通过设置 opacity 属性或在使用 rgba (红-绿-蓝-透明度) 值来指定颜色时完成。

以下示例渲染了三种不同颜色的圆圈,它们的透明度都是 50%。前两个使用不透明度属性,第三个使用透明颜色规范进行填充。

<circle cx="150" cy="150" r="100" 
        style="fill:red" opacity="0.5" />
<circle cx="250" cy="150" r="100" 
        style="fill:green" opacity="0.5" />
<circle cx="200" cy="250" r="100" 
        style="fill:rgba(0, 0, 255, 0.5)" />

透明度

注意

bl.ock (3.20): goo.gl/xRzArg

你可能已经注意到 SVG 元素以特定的顺序重叠,某些元素看起来更接近并遮挡了后面的元素。让我们通过一个将三个圆圈重叠在一起的示例来检查这一点:

<circle cx="150" cy="150" r="100" style="fill:red" />
<circle cx="250" cy="150" r="100" style="fill:green" />
<circle cx="200" cy="250" r="100" style="fill:blue" />

层

注意

bl.ock (3.21): goo.gl/hO4xmc

蓝色圆圈绘制在绿色圆圈之前,绿色圆圈绘制在红色圆圈之前。这个顺序是由 SVG 标记中指定的顺序定义的,每个后续元素都是在上一个元素之上渲染的。

注意

如果你使用过其他图形包或 UI 工具,你会知道它们通常提供了一个称为 Z-顺序的概念,其中 Z 是一个伪维度,元素的绘制顺序是从最低到最高的 Z-顺序。SVG 不提供这种功能,但我们在后面的章节中将会看到,我们可以在布局之前对选择进行排序来解决这个问题。

摘要

在本章中,你学习了如何使用 SVG 创建各种形状,如何使用 SVG 坐标来布局 SVG 元素,以及层如何影响渲染。你还学习了在 SVG 元素上执行变换,这在本书的示例中将被频繁使用,并成为使用 D3 创建视觉元素的基本部分。

在下一章中,我们将回到 D3.js 的焦点,特别是我们将利用本章学到的 SVG 知识,使用 D3 和 SVG 元素创建数据驱动的条形图。

第四章。创建条形图

现在我们已经检查了绑定数据和用 D3 生成 SVG 视觉元素,我们将在本章中关注使用 SVG 创建条形图。本章的示例将使用一个静态整数数组,并使用该数据来计算条形的高度、位置,为条形添加标签,以及添加边距和坐标轴以帮助用户理解数据中的关系。

在本章中,我们将涵盖以下主题:

  • 创建与数据绑定的条形系列

  • 计算条形的位置和高度

  • 使用组来均匀定位表示条形的多个元素

  • 向图表添加边距

  • 创建和操作坐标轴的样式和标签

  • 向图表添加坐标轴

基本条形图

我们已经探讨了在第一、二、三章中基于数据绘制一系列条形所需的所有内容。本章的第一个例子将利用 SVG 矩形来绘制条形。我们现在需要做的是根据数据计算条形的大小和位置。

我们条形图的代码可在以下位置找到。在您的浏览器中打开此链接,我们将逐步讲解代码是如何创建随后的视觉效果的。

注意

bl.ock (4.1): goo.gl/TQo2sX

基本条形图

代码从声明要表示为图的数据库开始。这个例子使用了一个硬编码的整数数组。我们将在本书的后面部分探讨更复杂的数据类型;现在,我们只是从这开始,以便熟悉绑定过程以及创建条形的过程:

var data = [55, 44, 30, 23, 17, 14, 16, 25, 41, 61, 85,
            101, 95, 105, 114, 150, 180, 210, 125, 100, 71,
            75, 72, 67];

现在我们定义两个变量,分别定义每个条形的宽度和每个条形之间的间距:

var barWidth = 15, barPadding = 3;

我们需要将每个条形的高度相对于数据中的最大值进行缩放。这是通过使用d3.max()函数来确定的。

var maxValue = d3.max(data);

现在我们通过将其放置在文档的主体内部来创建主要的 SVG 元素,并分配一个宽度和高度,我们知道这将容纳我们的视觉元素。最后,作为一个将在整本书中使用的实践,我们将在 SVG 标签中附加一个顶级组元素。然后,我们将条形放置在这个组内,而不是直接放置在 SVG 元素中:

var graphGroup = d3.select('body')
    .append('svg')
    .attr({ width: 1000, height: 250 })
    .append('g');

小贴士

我发现使用顶级组进行这种做法很有用,因为它便于在同一 SVG 中放置多个复杂的视觉元素,例如在创建仪表板的情况下。

在这个例子中,我们不会缩放数据,并假设容器是适当的大小以容纳图表。我们将在第五章中探讨更好的方法来做这件事,包括计算条形的位置,使用数据和比例。我们只是力求目前保持简单。

我们需要执行两步数学运算才能计算出条形的xy位置。我们将这些条形定位在graphGroup的底部和左侧的像素位置。我们需要两个函数来计算这些值。第一个函数计算条形左侧的x位置:

function xloc(d, i) { return i * (barWidth + barPadding); }

在绑定过程中,这将传递当前数据项及其在data数组中的位置。实际上我们不需要这个值来进行计算。我们只需根据数组位置计算条形宽度与填充之和的倍数。

由于 SVG 使用左上角为原点,我们需要计算从图表顶部到我们开始绘制条形向下到视觉底部位置的距离:

function yloc(d) { return maxValue - d; }

当我们定位每个条形时,我们将使用一个 translate 变换,利用这些函数中的每一个。我们可以通过声明一个函数来实现这一点,该函数给定当前数据项及其数组位置,根据这些数据和函数返回计算出的 transform 属性字符串:

function translator(d, i) {
    return "translate(" + xloc(d, i) + "," + yloc(d) + ")";
   }

现在我们需要做的是从数据中生成 SVG 视觉元素:

barGroup.selectAll("rect")
    .data(data)
    .enter()
    .append('rect')
    .attr({
        fill: 'steelblue',
        transform: translator,
        width: barWidth,
        height: function (d) { return d; }
    });

只用几行代码就做得相当不错。但从图表中我们只能看出数据的相对大小。为了得到有效的数据可视化,我们需要比这更多的信息。

为条形添加标签

现在,我们将在每个条形的顶部添加一个标签,该标签包含数据项的值。此示例的代码可在以下链接找到:

注意

bl.ock (4.2): goo.gl/3ltkHT

以下图像展示了生成的视觉效果:

为条形添加标签

为了实现这一点,我们将修改我们的 SVG 生成方式:

  1. 每个条形都由一个 SVG 组而不是rect表示。

  2. 在代表条形的每个组内部,我们添加一个 SVG 元素和一个文本元素。

  3. 然后将该组定位,因此也定位了子元素。

  4. rect的大小设置如前,导致包含组扩展到相同的大小。

  5. 文本相对于其包含组的左上角定位。

通过以这种方式将这些元素分组,我们可以重用之前的定位代码,并利用组定位所有条形子视觉元素的优势。此外,我们只需要根据它们自己的组来调整子元素的大小和位置,这使得数学运算非常简单。此代码与之前的示例通过声明定位函数是相同的。

第一个变化是在创建表示条形的选择器:

var barGroups = g.selectAll('g')
    .data(data)
    .enter()
    .append('g')
    .attr('transform', translator);

代码现在不是创建rect,而是创建一个组元素。该组最初为空,并赋予将其移动到适当位置的变换。

使用barGroups引用的选择器,代码现在将rect追加到每个组中,同时设置适当的属性。

barGroups.append('rect')
    .attr({
        fill: 'steelblue',
        width: barWidth,
        height: function(d) { return d; }
    });

下一步是添加一个 text 元素来显示数据的值。我们将把这个文本放置在条形的最上方,并在条形中居中。

为了实现这一点,我们需要一个表示条形中点偏移的平移变换。这对于每个条形都是常见的,因此我们可以定义一个可重复使用的变量:

var textTranslator = "translate(" + barWidth / 2 + ",0)";

接下来,我们在每个组中添加一个文本元素,设置其文本(数据的字符串值)、适当的文本属性,最后是字体样式。

barGroups.append('text')
    .text(function(d) { return d; })
    .attr({
        fill: 'white',
        'alignment-baseline': 'before-edge',
        'text-anchor': 'middle',
        transform: textTranslator
    })
    .style('font', '10px sans-serif');

这相当简单,而且条形上的标签看起来很漂亮,但我们的图表仍然非常需要轴。我们将在下一节中探讨如何添加这些轴。

边距和轴

在图表中添加轴将使读者更好地理解图表的范围和数据值之间的关系。D3.js 内置了非常强大的结构,允许我们创建轴。

D3.js 中的轴是基于另一个称为刻度的概念。虽然刻度本身非常有用(我们将在第五章使用数据和刻度中更详细地介绍刻度),但在本章的剩余部分,我们将探讨如何使用它们在我们的条形图中创建基本轴。

然而,在我们讨论轴之前,我们首先简要但重要地探讨一下边距的概念,以及为我们的条形图添加边距以留出空间的概念。

在条形图中创建边距

在图表中,边距有几个实际用途。它们可以用来在图表和其他页面内容之间提供空间,使读者在他们的可视化内容和其他内容之间有清晰的视线。然而,边距的实际用途是为一或多个边的可视化提供空间,以便提供轴。

以下图像展示了我们希望通过边距实现的效果:

在条形图中创建边距

灰色部分是我们将放置现有图表的位置。然后,根据我们决定使用的轴(左、上、右、下),我们需要在我们的可视化中为渲染这些轴提供空间。请注意,单个图表可以使用任何或所有边距为不同的轴,因此为所有这些构建代码是一个好习惯。

在 D3.js 应用中,这通常是通过一个称为 边距约定 的概念来执行的。我们将通过一个示例来展示如何使用这个概念为我们的图表添加边距。此外,我们不会使用布局的静态大小,而是将根据示例中的数据点的数量计算图表的高度和宽度。

要开始,请从以下信息框中加载示例。

注意

bl.ock (4.3): goo.gl/HTZ2NG

代码的结果可视化可以在以下图表中看到:

在条形图中创建边距

除了边距之外,这个例子还在图表后面的区域添加了一个灰色背景。这突出了用于图表的区域,并相对于添加的边距强调了它。它还在主要 SVG 元素周围添加了一个矩形,以突出其边界,因为它有助于我们看到添加到图形中的边距范围。

让我们逐步分析这个示例,并检查它与上一个示例有何不同。我们首先计算条形区域的实际宽度:

var graphWidth = data.length * (barWidth + barPadding)
                 - barPadding;

现在我们声明一个代表我们边距大小的 JavaScript 对象:

var margin = { top: 10, right: 10, bottom: 10, left: 50 };

使用这些值,我们可以计算整个可视化的总大小:

var totalWidth = graphWidth + margin.left + margin.right;
var totalHeight = maxValue + margin.top + margin.bottom;

现在我们可以创建主要的 SVG 元素,并将其设置为所需的精确大小:

    var svg = d3.select('body')
        .append('svg')
        .attr({ width: totalWidth, height: totalHeight });

为了视觉效果,以下代码添加了一个矩形,显示了主要 SVG 元素的边界:

svg.append('rect').attr({
    width: totalWidth,
    height: totalHeight,
    fill: 'white',
    stroke: 'black',
    'stroke-width': 1
});

现在我们添加一个组来包含图形的主要部分:

var graphGroup = svg
    .append('g')
    .attr('transform', 'translate(' + margin.left + ',' +
                                      margin.top + ")");

为了强调实际图形的区域,我们在组中添加了一个灰色的 rect

graphGroup.append('rect').attr({
    fill: 'rgba(0,0,0,0.1)', 
    width: totalWidth – (margin.left + margin.right),
    height: totalHeight - (margin.bottom + margin.top)
});

代码的其余部分保持不变。

到目前为止,我们已经在图形周围添加了边距,并在左侧为绘制坐标轴腾出了空间。在我们将其放入可视化之前,让我们先看看创建坐标轴的示例,以了解其中涉及的一些概念。

创建坐标轴

为了演示坐标轴的创建,我们将从创建一个适合放置在图形底部的坐标轴开始,称为底部坐标轴。这是 D3.js 默认创建的坐标轴类型。因此,我们将从这里开始,然后在查看一些与坐标轴相关的概念之后,再检查如何更改方向。

以下是我们将要逐步讲解的示例代码,它将生成后续的坐标轴:

注意

bl.ock (4.4): goo.gl/TyDAH6

创建坐标轴

在我们的示例中,我们使用以下代码行创建比例和坐标轴:

var scale = d3.scale
    .linear()
    .domain([0, maxValue])
    .range([0, width]);

var axis = d3.svg.axis().scale(scale);
svg.call(axis);

要创建坐标轴,我们首先需要使用 d3.scale() 创建一个比例对象。比例会告知坐标轴它将表示的值的范围(称为),以及坐标轴在视觉中应该渲染的整体大小(称为范围)。在这个例子中,我们使用的是线性比例。线性比例会告知坐标轴,值将从较低值线性插值到较高值,在这种情况下,从 0 到 210。

注意

D3.js 比例除了用于坐标轴之外还有其他用途。我们将在第五章使用数据和比例中探讨这些用途。

然后使用 d3.svg.axis() 函数创建坐标轴,并通过调用 .scale() 方法传递比例。

坐标轴比例随后需要与一个选择关联,这通过使用 .call() 函数来完成。这通知 D3.js,当它渲染视觉元素时,应该调用坐标轴函数来渲染自身。

这种感觉与我们迄今为止创建视觉元素的方式略有不同。D3.js 使用这种技术,因为坐标轴是一组复杂的 SVG 元素,需要生成。.call() 的使用允许我们在渲染管道中将复杂的渲染逻辑分离成函数调用,D3.js 的设计就是为了以这种方式渲染坐标轴。

坐标轴上的标签是由 D3.js 自动生成的,并基于域的值。坐标轴的可视化大小由范围指定。在这种情况下,由于这是一个底部坐标轴,标签从 0 的最小值开始,D3.js 使用 20 的间隔来生成标签。最后一个适合的标签是 200,因此 D3.js 实际上并没有为 210 的最大值创建标签。

注意

在输出中,标签 0 被裁剪了。这是因为坐标轴位于 SVG 元素的左侧。这种方向使得轴上的线是平齐的。由于第一个标签的文本在刻度上是居中对齐的,其左半部分被裁剪了。这可以通过平移轻松修复,我们将在将坐标轴放置在我们图表旁边时进行考察。

使用开发者工具检查渲染后的坐标轴,你会看到 D3.js 在生成坐标轴上所付出的努力:

创建坐标轴

D3.js 所做的是为坐标轴上的每个刻度生成一个组,以及一个渲染坐标轴线的单个路径。每个刻度组本身由表示坐标轴刻度的线和刻度上的标签组成。

检查输出,你会注意到实际上我们没有在轴上看到任何刻度。这使你难以意识到与标签关联的实际坐标轴上的点。这是由于默认的样式造成的。我们将在下一节中使这个坐标轴看起来更好。

我们为什么看不到坐标轴上的刻度,是因为表示坐标轴的路径的默认厚度。我们可以通过简单地修改表示坐标轴的路径以及刻度的样式来更改它。

在你的浏览器中打开以下示例,了解如何完成这项任务:

注意

bl.ock (4.5): goo.gl/xmSf2g

创建坐标轴

此代码进行了一些小的修改,以便能够像以下代码部分所示那样更改样式:

var axisGroup = svg.append('g');
var axis = d3.svg.axis().scale(scale);

var axis = d3.svg.axis().scale(scale);
var axisNodes = axisGroup.call(axis);
var domain = axisNodes.selectAll('.domain');
domain.attr({
    fill: 'none',
    'stroke-width': 1,
    stroke: 'black'
});
var ticks = axisNodes.selectAll('.tick line');
ticks.attr({
    fill: 'none',
    'stroke-width': 1,
    stroke: 'black'
});

第一个变化是,我们创建了一个组,用变量 axisGroup 表示,来保存生成的坐标轴。这将用于选择表示刻度和坐标轴线的 SVG 元素,并更改它们的样式。

提示

总是将轴放入一个组中是一个好的做法。这有助于进行像我们在本例中执行的风格更改。此外,几乎总是需要将轴转换到可视化中的特定位置。轴本身不能转换,所以将其放入一个组元素中,然后转换组来完成这个任务。

其次,代码捕获了在 axisNodes 变量中生成的轴的节点。使用 axisNode,我们可以进行两个额外的选择来找到轴中的特定元素:一个用于具有 domain 类的元素,另一个用于具有 line 类的线条元素。使用这两个选择的每个结果,代码随后设置 fillstrokestroke-width 属性,使它们都变成一像素厚的黑色线条。

改变轴的方向

D3.js 轴可以使用 .orient() 函数渲染成四种不同的方向,该函数将方向名称传递给轴。以下表格显示了可以使用的方向名称:

'top' 水平轴,刻度和标签位于轴线的上方。
'bottom' 水平轴,刻度和标签位于轴线的下方(默认)
'left' 垂直轴,刻度和标签位于轴线的左侧
'right' 垂直轴,刻度和标签位于轴线的右侧

实际上,这些都与图表的四个侧面之一有关,例如之前提到的边距。这个函数对轴在视觉中的位置没有影响(我们必须自己完成)。相反,它决定了轴线的水平或垂直,以及水平轴的标签是在顶部还是底部,或者垂直轴的标签是在左侧还是右侧。

为了演示这一点,我们将快速检查顶部、右侧和左侧的方向。打开以下链接查看顶部轴的示例:

注意

bl.ock (4.6): bl.ocks.org/d3byex/8791783ee37ab76a8517

与前一个示例相比,这里有两个小的修改。主要变化是我们创建轴时调用了 .orient('top')

var axis = d3.svg.axis()
    .orient('top')
    .scale(scale);

第二个变化是我们需要将轴沿 Y 轴向下平移。我们使用以下语句来完成这个操作:

axisGroup.attr('transform', 'translate(0,50)');

上述示例的结果如下:

改变轴的方向

方向的改变使得标签和刻度移动到了轴线的顶部而不是下方。

转换的需求可能稍微有点微妙。如果轴没有被转换,我们在渲染结果中看到的将是一条位于顶部的黑色线条。这是因为轴的位置是相对于绘制轴线的路径而言的。在这种情况下,线条将位于 y = 0,刻度和文本会被裁剪,因为它们位于线条之上且不可见。

现在打开以下示例的代码,它渲染了一个右侧方向的坐标轴。我们不会检查代码,因为它只是调用 .orient('right') 的一个简单更改。

注意

bl.ock (4.7): goo.gl/H16kEo

上述代码的结果如下:

改变坐标轴方向

以下示例演示了一个左侧方向的坐标轴。这又是一个简单的参数更改到 .orient()。此外,代码还将坐标轴向右移动一点,因为刻度和标签会被截断在左侧。

注意

bl.ock (4.8): goo.gl/CNEFyV

结果如下:

改变坐标轴方向

反转坐标轴上的标签

我们想在条形图上放置一个左侧坐标轴,本质上就是示例 4.8 的输出。但如果你检查坐标轴,你会注意到标签是从上到下递增的。我们的图表在底部表示 0,值向上递增。这个坐标轴对我们图表来说不合适。

这种标签反转是代码中的一个非常简单的更改。打开以下示例:

注意

bl.ock (4.9): goo.gl/wsm9Ab

代码与示例 4.8 相同,除了一个更改。

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

我们更改传递给域的值的顺序,这将基本上反转标签的顺序。这给我们以下结果:

反转坐标轴上的标签

标签已经反转成我们想要的顺序。注意现在标签 0 在底部被截断。我们将在下一个示例中修复这个问题,当我们把坐标轴与条形图结合起来时。

将坐标轴添加到图表中

现在我们已经拥有了创建带有坐标轴的条形图所需的一切。基本上,我们只需要将示例 4.3 中的代码与示例 4.9 中的坐标轴代码结合起来。以下示例正是这样做的:

注意

bl.ock (4.10): goo.gl/MsKhUk

结果如下所示,这正是我们想要的图表:

将坐标轴添加到图表中

本示例中的代码与示例 4.3 中的代码相同,直到我们创建包含坐标轴的组为止:

var leftAxisGroup = svg.append('g');
var axisPadding = 3;
leftAxisGroup.attr({ 
    transform: 'translate(' + (margin.left - axisPadding) + ',' 
                            + margin.top + ')' });

这里的更改是将坐标轴沿 X 轴平移左边缘的宽度,沿 Y 轴平移顶边缘的大小。为了美观,代码只是简单地用三个像素的填充渲染坐标轴。

注意

还要注意,由于我们在底部有边距空间,0 标签不再被截断。

摘要

在本章中,你扩展了使用 D3 从整数集合创建条形图的知识。你学习了如何根据数据定位和调整每个元素的大小,以及如何定位包含多个表示单个条形的多视觉数据组——具体来说,如何在条形的顶部添加表示底层数据的值的标签。

我们随后检查了 D3.js 中用于创建坐标轴的功能。我们介绍了比例的概念,这是实现坐标轴的一个重要方面。我们进一步探讨了坐标轴的不同方向,以及如何反转坐标轴上标签的顺序。最后,我们将坐标轴和条形图结合起来,有效地展示了数据可视化。

尽管我们的条形图在这个例子中看起来很棒,但我们仍然会面临几个问题。图表的整体大小与数据的实际值相关。这对于演示条形图可视化的构建是方便的,但如果我们处理的数据不是整数,或者数据值非常小或非常大呢?我们可能根本看不到条形,或者条形可能太大,以至于超过了主 SVG 元素的大小。

在下一章中,我们将通过学习更多关于比例的知识来解决这些问题。比例将提供一种特别简单的方法,将数据映射到可视化的物理尺寸。你还将了解如何从外部源加载数据,以及如何处理比简单整数结构更复杂的数据。

第五章。使用数据和尺度

在第四章中,创建条形图,你学习了如何创建基于在应用程序中静态编码的整数序列的条形图。尽管生成的图表看起来相当不错,但在数据提供和渲染的方式上存在几个问题。

问题是数据是硬编码在应用程序中的。几乎无一例外,我们将从外部源加载数据。D3.js 提供了一套丰富的功能,可以从网络上的不同格式的源加载数据。在本章中,你将学习如何使用 D3.js 从网络以 JSON、CSV 和 TSV 格式加载数据。

在上一章给出的示例中,数据存在第二个问题是它只是一个整数的数组。数据通常会被表示为具有多个属性的对象集合,其中许多属性我们不需要用于我们的可视化。它们也经常被表示为字符串而不是数值。在本章中,你将学习如何选择你想要的数据并将其转换为所需的数据类型。

在我们之前的条形图中,还有一个问题是假设数据中表示的值与可视化中的像素有直接映射。这通常不是情况,我们需要将数据缩放到浏览器中渲染的大小。这可以通过使用尺度轻松实现,我们已经相对于坐标轴进行了考察,现在我们将它们应用于数据。

在上一个示例中,最后一个问题是我们的代码是手动计算条的大小和位置的。条形图在 D3.js 应用程序中很常见,并且有内置的函数可以自动为我们完成这项工作。我们将考察如何使用这些函数来简化我们的代码。

因此,让我们开始吧。在本章中,我们将具体涵盖以下主题:

  • 从网络加载 JSON、TSV 或 CSV 格式的数据

  • 使用.map()函数从对象中提取字段

  • 将字符串值转换为它们的代表数值数据类型

  • 使用线性尺度转换连续值

  • 使用序数尺度映射离散数据

  • 使用带区计算条的大小和位置

  • 将我们迄今为止学到的知识应用于创建使用真实数据的丰富条形图

数据

数据是创建数据可视化的核心。在 D3 中创建的几乎每个视觉元素都需要绑定到一些数据上。这些数据可以来自多个来源。它可以明确地编码在可视化中,从外部源加载,或者由其他数据的操作或计算结果而来。

用于创建 D3.js 可视化的数据大多是从文件、网络服务或 URL 获取的。这些数据通常以 JSON、XML、CSV(逗号分隔值)和 TSV(制表符分隔值)等多种格式之一存在。我们需要将这些格式的数据转换为 JavaScript 对象,而 D3.js 提供了方便的函数来完成这项工作。

使用 D3.js 加载数据

D3.js 提供了多个辅助函数,可以从浏览器外部加载数据,并将其同时转换为 JavaScript 对象。你可能遇到的最常见的几种数据格式,我们也将涉及,包括:

  • JSON

  • TSV

  • CSV

注意

你可能已经注意到,在我们的示例中,我排除了 XML。D3.js 确实有加载 XML 的函数,但与 JSON、TSV 和 CSV 不同,加载的结果不会自动转换为 JavaScript 对象,需要使用 JavaScript XML/DOM 功能进行额外的操作。由于大多数你目前会遇到的情况都将由这三种格式处理,如果不仅仅是 JSON,而 JSON 已经成为网络几乎无处不在的数据格式,因此 XML 将不在此文本的范围内。

为了演示处理所有这些数据格式,我们将检查一个数据集,我将其整理并放置在 GitHub 上,该数据集代表了 AMC 电视剧《行尸走肉》第五季的观众收视率。

注意

这个 GitHub 是手动使用 en.wikipedia.org/wiki/The_Walking_Dead_(season_5) 上的数据构建的。

加载 JSON 数据

JavaScript 对象表示法JSON)格式的数据便于转换为 JavaScript 对象。它是一个非常灵活的格式,支持命名属性以及层次化数据。

本例的 JSON 数据存储在 GitHub 上,可在 gist.githubusercontent.com/d3byex/e5ce6526ba2208014379/raw/8fefb14cc18f0440dc00248f23cbf6aec80dcc13/walking_dead_s5.json 找到。

注意

URL 稍显复杂。你可以直接访问包含此数据三个版本的 gist goo.gl/OfD1hc

点击链接将在浏览器中显示数据。此文件包含一个 JavaScript 对象数组,每个对象都有六个属性,代表该节目的单个剧集。前两个对象如下:

[
{
  "Season": 5,
  "Episode":  1,
  "SeriesNumber": 52,
  "Title": "No Sanctuary",
  "FirstAirDate": "10-12-2014",
  "USViewers": 17290000
},
{
  "Season": 5,
  "Episode":  2,
  "SeriesNumber": 53,
  "Title": "Strangers",
  "FirstAirDate": "10-19-2014",
  "USViewers": 15140000
},
…
]

可以使用 d3.json() 函数将此数据加载到我们的 D3.js 应用程序中。这个函数,像 D3.js 中的许多其他函数一样,是异步执行的。它接受两个参数:要加载的数据的 URL,以及当数据加载完成时被调用的回调函数。

以下示例演示了加载数据并显示数组中的第一个项目。

注意

bl.ock (5.1): goo.gl/Qe63wH

加载数据的主要代码如下:

var url = "https://gist.githubusercontent.com/d3byex/e5ce6526ba2208014379/raw/8fefb14cc18f0440dc00248f23cbf6aec80dcc13/walking_dead_s5.json";
d3.json(url, function (error, data) {
    console.log(data[0]);
});
console.log("Data in D3.js is loaded asynchronously");

此示例没有可见的输出,但输出被写入 JavaScript 控制台:

"Data in D3.js is loaded asynchronously"
[object Object] {
  Episode: 1,
  FirstAirDate: "10-12-2014",
  Season: 5,
  SeriesNumber: 52,
  Title: "No Sanctuary",
  USViewers: 17290000
}

注意,D3.js 中的数据加载是异步进行的。console.log() 调用的输出显示数据是异步加载的,并且首先执行。稍后,当数据加载完成后,我们会在第二次调用 console.log() 中看到输出。

回调函数本身有两个参数。第一个是一个表示错误的对象的引用。在这种情况下,该变量将非空并包含详细信息。非空表示数据已加载,并由数据变量表示。

加载 TSV 数据

TSV 是你在进行足够的 D3.js 编程时会遇到的一种数据类型。在 TSV 文件中,值由制表符分隔。通常,文件的第一个行是每个值的名称的制表符分隔序列。

TSV 文件比 JSON 文件更简洁,并且通常由许多非 JavaScript 基础的系统自动生成。

TSV 格式的剧集数据可在以下链接找到:gist.githubusercontent.com/d3byex/e5ce6526ba2208014379/raw/8fefb14cc18f0440dc00248f23cbf6aec80dcc13/walking_dead_s5.tsv.

点击链接,你将在浏览器中看到以下内容:

Season Episode SeriesNumber Title FirstAirDate USViewers
5 1 52 No Sanctuary 10-12-2014 17290000
5 2 53 Strangers 10-19-2014 15140000 
5 3 54 Four Walls and a Roof 10-26-2014 13800000
5 4 55 Slabtown 11-02-2014 14520000
5 5 56 Self Help 11-09-2014 13530000
5 6 57 Consumed 11-16-2014 14070000
5 7 58 Crossed 11-23-2014 13330000
5 8 59 Coda 11-30-2014 14810000
5 9 60 What Happened and What's Going On 02-08-2015 15640000
5 10 61 Them 02-15-2015 12270000
5 11 62 The Distance 02-22-2015 13440000
5 12 63 Remember 03-01-2015 14430000
5 13 64 Forget 03-08-2015 14530000
5 14 65 Spend 03-15-2015 13780000
5 15 66 Try 03-22-2015 13760000
5 16 67 Conquer 03-29-2015 15780000

我们可以使用 d3.tsv() 从此文件加载数据。以下包含示例代码:

注意

bl.ock (5.2): goo.gl/nlq8jy

代码与 JSON 示例相同,只是 URL 和 d3.json() 调用不同。然而,控制台输出却是不同的。

[object Object] {
  Episode: "1",
  FirstAirDate: "10-12-2014",
  Season: "5",
  SeriesNumber: "52",
  Title: "No Sanctuary",
  USViewers: "17290000"
}

注意,属性 EpisodeSeasonSeriesNumberUSViewers 现在是字符串类型,而不是整数类型。TSV 文件没有像 JSON 那样暗示类型的手段,所以所有内容默认为字符串。这些通常需要转换为其他类型,我们将在下一节关于映射和数据转换中探讨这一点。

加载 CSV 数据

CSV 格式与 TSV 类似,但字段分隔符不是制表符,而是逗号。CSV 是一种相当常见的格式,类似于电子表格应用程序的输出,常用于创建许多组织中其他应用程序消费的数据。

数据的 CSV 版本可在以下链接找到:gist.githubusercontent.com/d3byex/e5ce6526ba2208014379/raw/8fefb14cc18f0440dc00248f23cbf6aec80dcc13/walking_dead_s5.csv.

打开链接,你将在浏览器中看到以下内容:

Season,Episode,SeriesNumber,Title,FirstAirDate,USViewers
5,1,52,No Sanctuary,10-12-2014,17290000
5,2,53,Strangers,10-19-2014,15140000 
5,3,54,Four Walls and a Roof,10-26-2014,13800000
5,4,55,Slabtown,11-02-2014,14520000
5,5,56,Self Help,11-09-2014,13530000
5,6,57,Consumed,11-16-2014,14070000
5,7,58,Crossed,11-23-2014,13330000
5,8,59,Coda,11-30-2014,14810000
5,9,60,What Happened and What's Going On,02-08-2015,15640000
5,10,61,Them,02-15-2015,12270000
5,11,62,The Distance,02-22-2015,13440000
5,12,63,Remember,03-01-2015,14430000
5,13,64,Forget,03-08-2015,14530000
5,14,65,Spend,03-15-2015,13780000
5,15,66,Try,03-22-2015,13760000
5,16,67,Conquer,03-29-2015,15780000

使用 d3.csv() 加载前面数据的示例可在以下链接找到:

注意

bl.ock (5.3): goo.gl/JUX9CA

结果与 TSV 示例中的结果相同,即所有字段都作为字符串加载。

映射字段和将字符串转换为数字

我们将使用这些数据(其 CSV 源)来渲染一个条形图,显示每个集的观众量比较。如果我们直接使用这些字段创建条形图,这些值将被错误地解释为字符串类型而不是数字,我们的结果图将是不正确的。

此外,为了创建一个显示观众量的条形图,我们不需要这些属性,可以省略 SeasonSeriesNumberFirstAirDate 字段。在这个数据集中这不是真正的问题,但有时数据可以有数百列和数十亿行,因此提取仅必要的属性以帮助节省内存将更加高效。

这些可以通过简单的 for 循环完成,将所需的字段复制到一个新的 JavaScript 对象中,并使用其中一个解析函数转换数据。D3.js 给我们提供了一个更好的方法,一种函数式的方法,来执行这个任务。

D3.js 为我们提供了一个 .map() 函数,可以在数组上使用,它将对数组的每个项目应用一个函数。这个函数返回一个 JavaScript 对象,D3.js 收集所有这些对象并将它们作为一个数组返回。这为我们提供了一种简单的方法,只需一个语句就可以选择我们想要的属性并转换数据。

要演示这一点,请打开以下链接提供的示例:

注意

bl.ock (5.4): goo.gl/ex2e8C

代码中的重要部分是 data.map() 的调用:

var mappedAndConverted = data.map(function(d) {
    return {
        Episode: +d.Episode,
        USViewers: +d.USViewers,
        Title: d.Title
    };
});
console.log(mappedAndConverted);

传递给 .map() 的函数为数据数组中的每个项目返回一个新的 JavaScript 对象。这个新对象仅包含三个指定的属性。这些对象都被 .map() 收集并存储在 mappedAndConverted 变量中。

以下代码显示了新数组中的前两个对象:

[[object Object] {
  Episode: 1,
  Title: "No Sanctuary",
  USViewers: 17290000
}, [object Object] {
  Episode: 2,
  Title: "Strangers",
  USViewers: 15140000
},

注意,EpisodeUSViewers 现在是数值。这是通过应用一元 + 运算符实现的,它将字符串转换为适当的数值类型。

规模

规模是 D3.js 提供的函数,用于将一组值映射到另一组值。输入值集被称为域,输出是范围。规模存在的基本原因是防止我们编写循环,并做大量的数学运算来实现这些转换。这是一件非常有用的事情。

规模通常分为三类:定量、序数和时间尺度。在每个尺度的类别中,D3.js 提供了一系列具体的实现,用于完成特定类型的数据映射,这些数据映射对于数据可视化非常有用。

覆盖每种类型的刻度示例将占用比本书可用的空间更多,同时也会变得难以阅读。我们将考察几个常用的刻度,这有点像 80/20 规则,在这里我们涵盖的少数几个刻度将是你使用刻度时最常用的。

线性刻度

线性刻度是一种定量刻度,可以说是最常用的刻度之一。所执行的映射是线性的,即输出范围是通过输入域的线性函数计算得出的。

使用线性刻度的良好例子是我们行尸走肉观众的收视率数据。我们需要从这个数据中绘制条形图;但如果我们使用书中之前使用过的代码,我们的条形图将会非常高,因为那个代码在值和像素之间有一个一对一的映射。

假设图表上条形图区域的高度为 400 像素。我们希望将最低收视率值映射到 100 像素高的条形图,并将最高收视率值映射到 400 像素。以下示例执行此任务:

注意

bl.ock (5.5): goo.gl/dgg0zf

代码开始,就像 CSV 示例一样,首先加载数据并映射/转换它。下一个任务是确定最小和最大收视率值:

var viewership = mappedAndConverted.map(function (d) { 
     return d.USViewers; 
});
var minViewership = d3.min(viewership);
var maxViewership = d3.max(viewership);

接下来,我们定义几个变量来表示我们希望条形图的最小和最大高度:

var minBarHeight = 100, maxBarHeight = 400;

刻度随后创建如下:

var yScale = d3.scale
    .linear()
    .domain([minViewership, maxViewership])
    .range([minBarHeight, maxBarHeight]);

我们现在可以将yScale对象当作一个函数来使用。以下代码将记录缩放最小和最大收视率值的结果:

console.log(minViewership + " -> " + yScale(minViewership));
console.log(maxViewership + " -> " + yScale(maxViewership));

检查控制台输出,我们可以看到缩放产生了预期的值:

"12270000 -> 100"
"17290000 -> 400"

序数刻度

序数刻度在某种程度上类似于字典对象。域和范围中的值是离散的。对于每个唯一的输入值,必须在范围中有一个条目,并且该值必须映射到范围中的单个值。

有几种常见的序数刻度用法,我们将考察本书剩余部分中我们将使用的四种常见用法。

将颜色字符串映射到代码

打开以下链接查看序数刻度的示例。此示例不使用行尸走肉的数据,而是简单地演示了将表示基本颜色的字符串字面量映射到相应的颜色代码。

注意

bl.ock (5.6): goo.gl/DezcUN

刻度创建如下:

var colorScale = d3.scale.ordinal()
    .domain(['red', 'green', 'blue'])
    .range(['#ff0000', '#00ff00', '#0000ff']);

我们现在可以将任何范围值传递给colorScale,如下所示:

console.log(colorScale('red'),
    colorsScale('green'),
    colorScale('blue'));

检查控制台输出,我们可以看到映射的结果如下:

"#ff0000"
"#00ff00"
"#0000ff"

将整数映射到颜色刻度

D3.js 附带了一些称为分类刻度的特殊内置刻度。听起来像是一个复杂的术语,但它们只是将一组整数映射到唯一的颜色(在该刻度内唯一)。

当你有一组基于 0 的整数键的数据集,并且你想为每个键使用独特的颜色,但你不想手动创建所有映射(就像我们在上一个示例中为三个字符串所做的那样)时,这些很有用。

打开以下链接,查看使用 10 种颜色分类尺度的示例:

注意

bl.ock (5.7): goo.gl/RSW9Qa

上述示例生成了 10 个相邻的矩形,每个矩形都从category10()颜色尺度中获取一个独特的颜色。当你执行这个示例时,你将在浏览器中看到这一点。

将整数映射到颜色尺度

此示例首先创建一个包含从 0 到 9 的 10 个整数的数组。

var data = d3.range(0, 9);

接下来创建尺度:

var colorScale = d3.scale.category10();

现在我们可以将整数绑定到矩形上,并通过传递值到colorScale函数来设置每个矩形的填充:

var svg = d3.select('body')
    .append('svg')
    .attr({width: 200, height: 20});

svg.selectAll('rect')
    .data(data)
    .enter()
    .append('rect')
    .attr({
        fill: function(d) { return colorScale(d); },
        x: function(d, i) { return i * 20 },
        width: 20,
        height: 20
    });

D3.js 提供了四组分类颜色尺度,可以根据你的场景使用。你可以在 D3.js 文档页面上查看它们,网址为github.com/mbostock/d3/wiki/Ordinal-Scales

使用范围带的序数尺度

在第四章中,创建条形图,当我们绘制图表时,我们根据固定的条形大小和填充计算条形的位置。这实际上是一种非常不灵活的完成任务的方式。D3.js 为我们提供了一个特殊的尺度,我们可以使用它,给定域值和基本宽度,这将告诉我们每个条形的起始和结束值,使得所有条形都能完美地位于范围内!

让我们使用以下示例中的特殊尺度来看看:

注意

bl.ock (5.8): goo.gl/OG3g7S

此示例创建了一个简单的序数尺度,使用.rangeBands()函数指定范围,而不是.range()。示例的整个代码如下:

var bands = d3.scale.ordinal()
    .domain([0, 1, 2])
    .rangeBands([0, 100]);
console.log(bands.range()); 
console.log(bands.rangeBand());

.range()函数将返回一个数组,其中的值表示.rangeBands()指定的范围的等间距划分的数量的范围。在这种情况下,范围的宽度是100,域中指定了三个项目;因此,结果是以下内容:

[0, 33.333333333333336, 66.66666666666667]

技术上,这个结果是表示每个带起始值的值。每个带的宽度可以通过.rangeBand()函数找到,在这种情况下返回以下内容:

33.333333333333336

这个宽度可能看起来很简单。如果我们可以直接计算.range()结果中两个相邻值之间的差异,为什么还要有这个函数?为了演示,让我们看看这个示例的轻微修改,链接如下。

注意

bl.ock (5.9): goo.gl/JPsuqh

这对.rangeBands()的调用进行了一次修改,添加了一个额外的参数,指定了条形之间应该存在的填充:

var bands = d3.scale.ordinal()
    .domain([0, 1, 2])
    .rangeRoundBands([0, 100], 0.1);

由于在带之间添加了填充,输出略有不同:

[3.2258064516129035, 35.483870967741936, 67.74193548387096]
29.032258064516128

每个带的宽度现在是 29.03,带之间的填充为 3.23(包括两个外带的外侧)。

填充的值是一个介于 0.0(默认值,结果为 0 填充)和 1.0 之间的值,结果为宽度为 0.0 的带。0.5 的值使填充的宽度与每个带相同。

可视化《行尸走肉》观众人数

现在,我们将本章的所有内容结合起来,以渲染《行尸走肉》所有剧集的观众人数条形图:

注意

bl.ock (5.10): goo.gl/T8d6OU

前一个示例的输出如下:

可视化《行尸走肉》观众人数

现在让我们逐步了解这是如何创建的。在从 JSON 文件加载数据后,首先执行的操作是提取 USViewership 值并确定最大值:

var viewership = data.map(function (d) {
    return d.USViewers;
});

var maxViewers = d3.max(viewership);

然后创建了各种变量,它们代表图表的各种度量指标,以及主要的 SVG 元素:

var margin = { top: 10, right: 10, bottom: 260, left: 85 };

var graphWidth = 500, graphHeight = 300;

var totalWidth = graphWidth + margin.left + margin.right;
var totalHeight = graphHeight + margin.top + margin.bottom;

var axisPadding = 3;

var svg = d3.select('body')
    .append('svg')
    .attr({ width: totalWidth, height: totalHeight });

创建用于容纳条形的容器:

var mainGroup = svg
    .append('g')
    .attr('transform', 'translate(' + margin.left + ',' + 
                                      margin.top + ")");

现在我们使用 .rangeBands() 创建一个用于条形的序数刻度尺。我们将使用它来计算条形的位置和填充:

var bands = d3.scale.ordinal()
    .domain(viewership)
    .rangeBands([0, graphWidth], 0.05);

我们还需要一个刻度尺来计算每个条形的高度:

var yScale = d3.scale
    .linear()
    .domain([0, maxViewers])
    .range([0, graphHeight]);

下面的函数用于创建条形的选取,以定位每个条形:

function translator(d, i) {
    return "translate(" + bands.range()[i] + "," +
                          (graphHeight - yScale(d)) + ")";
}

现在我们为每个条形的内容创建组:

var barGroup = mainGroup.selectAll('g')
    .data(viewership)
    .enter()
    .append('g')
    .attr('transform', translator);

接下来,我们添加条形的矩形:

barGroup.append('rect')
    .attr({
        fill: 'steelblue',
        width: bands.rangeBand(),
        height: function(d) { return yScale(d); }
    });

然后在条形上添加一个标签以显示确切的观众人数值:

barGroup.append('text')
    .text(function(d) { return d; })
    .style('text-anchor', 'start')
    .attr({
        dx: 10,
        dy: -10,
        transform: 'rotate(90)',
        fill: 'white'
    });

条形现在已完成,因此我们继续创建两个轴。我们首先从左侧轴开始:

var leftAxisGroup = svg.append('g');
leftAxisGroup.attr({
    transform: 'translate(' + (margin.left - axisPadding) + ',' +
                               margin.top + ')'
});

var yAxisScale = d3.scale
    .linear()
    .domain([maxViewers, 0])
    .range([0, graphHeight]);

var leftAxis = d3.svg.axis()
    .orient('left')
    .scale(yAxisScale);
var leftAxisNodes = leftAxisGroup.call(leftAxis);
styleAxisNodes(leftAxisNodes);

现在创建一个底部轴,显示标题:

var titles = data.map(function(d) { return d.Title; });
var bottomAxisScale = d3.scale.ordinal()
    .domain(titles)
    .rangeBands([axisPadding, graphWidth + axisPadding]);

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

var bottomAxisX = margin.left - axisPadding;
var bottomAxisY = totalHeight - margin.bottom + axisPadding;

var bottomAxisGroup = svg.append("g")
    .attr({ transform: 'translate(' + bottomAxisX + ',' + bottomAxisY + ')' });

var bottomAxisNodes = bottomAxisGroup.call(bottomAxis);
styleAxisNodes(bottomAxisNodes);

bottomAxisNodes.selectAll("text")
    .style('text-anchor', 'start')
    .attr({
        dx: 10,
        dy: -5,
        transform: 'rotate(90)'
});

下面的函数是用于样式化轴的可重用代码:

function styleAxisNodes(axisNodes) {
    axisNodes.selectAll('.domain')
        .attr({
            fill: 'none',
            'stroke-width': 1,
            stroke: 'black'
        });
    axisNodes.selectAll('.tick line')
        .attr({
            fill: 'none',
            'stroke-width': 1,
            stroke: 'black'
        });
}

摘要

在本章中,你学习了如何从网络加载数据并将其用作条形图的基础。我们从加载 JSON、CSV 和 TSV 格式的数据开始。你学习了如何使用 .map() 函数从这些数据中提取你需要的值,并探讨了将字符串值转换为数值值所需的问题和解决方案。

接下来,我们更详细地介绍了刻度尺,并探讨了如何使用刻度尺将数据从一个值域映射到另一个值域,以及如何将离散值,如颜色名称映射到颜色代码。我们介绍了分类刻度尺,这是一种将整数值映射到预定义颜色图的方法,以及我们将在示例中经常使用的一个概念。我们对刻度尺的考察以使用 .rangeBands() 的演示结束,并展示了它如何帮助我们确定预定义区域内条形的大小和位置。

我们通过将这些概念结合起来,形成了到目前为止我们生成条形图的最佳示例来结束本章。这展示了加载数据、使用数据轴和轴的多个刻度尺,以及使用 .rangeBands() 确定条形位置,以及不仅使用垂直轴还使用水平轴。

在下一章中,我们将从条形图扩展到另一种数据可视化类型——散点图(和气泡图)。

第六章:创建散点图和气泡图

在本章中,我们扩展了使用 D3.js 绘制数据的示例,以解释如何创建散点图和气泡图。与通过条形图可视化的单变量数据相比,散点图和气泡图可视化多变量数据。多变量数据由两个或更多变量组成,散点图使我们能够可视化两个变量,而气泡图将此扩展到三个或四个变量。

我们将首先创建一个简单的散点图,使用固定符号,基于股票相关性数据。我们开始使用实心圆作为符号,并将通过几个增强功能进行进展,包括使用颜色、轮廓和不透明度。我们将通过一个示例结束散点图,该示例使用多个重叠的数据集,每个数据集使用不同的符号和颜色。

当我们完成对气泡图创建的检查后,我们将扩展该示例以根据数据更改点的尺寸,然后根据分类信息着色点。最后一个示例将展示我们如何在单一的可视化中可视化四个不同的变量,以及视觉的使用如何帮助我们从底层信息中提取意义。

具体来说,在本章中,我们将涵盖以下主题:

  • 使用固定大小和实心点创建基本散点图

  • 使用轮廓而不是实心填充来使图表更易读

  • 添加网格线以帮助确定点的位置

  • 将散点图代码扩展到创建气泡图

创建散点图

散点图由两个轴组成,每个轴对应一个变量。每个轴可以是连续变量或分类变量。对于每个测量值(一个测量值XY 值的配对组合),在指定的位置在图上放置一个符号。最终结果是这样一个图,它允许观看者确定一个变量受另一个变量影响的程度。

我们前几个示例的基础将是一个数据集,该数据集表示 AAPL 和 MSFT 股票在 2014 年的每日相关性。为了创建散点图,这些数据的意义并不重要——它只是代表二维数据,其中每个股票的价值代表相应轴上的一个位置。

本例的数据可在goo.gl/BZkC8B找到。

在浏览器中打开此链接,您将看到以下数据的前几行:

Date,AAPL,MSFT
2014-01-02,-0.01406,-0.00668
2014-01-03,-0.02197,-0.00673
2014-01-06,0.00545,-0.02113
2014-01-07,-0.00715,0.00775
2014-01-08,0.00633,-0.01785
2014-01-09,-0.01277,-0.00643
2014-01-10,-0.00667,0.01435
2014-01-13,0.00524,-0.02941
2014-01-14,0.0199,0.02287
2014-01-15,0.02008,0.02739

绘制点

我们的第一个示例将演示在散点图中绘制点的过程。为了保持简单,它省略了轴和其他风格元素(这些将在下一个示例中添加)。

示例可在以下位置找到:

注意

bl.ock (6.1): goo.gl/Uv6aSj

结果图如下所示:

绘制点

现在我们来检查这是如何创建的。示例首先加载数据:

var url = "https://gist.githubusercontent.com/d3byex/520e6dcb30e673c149cc/raw/432623f00f6740021bdc13141612ac0b6196b022/corr_aapl_msft.csv";
d3.csv(url, function (error, rawData) {

注意

整个 URL 必须指定,因为显然,数据加载函数不会遵循重定向。

我们需要将 AAPL 和 MSFT 这两个属性从字符串转换为数字。我们通过创建一个新的包含XY属性的对象数组来实现这一点,其中 AAPL 映射到X,MSFT 映射到Y,这也转换了数据类型:

    var data = rawData.map(function(d) {
        return { X: +d.AAPL, Y: +d.MSFT }
    });

为了有效地缩放散点图,我们需要知道XY系列中数据的范围:

    var xExtents = d3.extent(data, function(d) { return d.X; });
    var yExtents = d3.extent(data, function(d) { return d.Y; });

这些值将帮助我们创建图表两个维度所需的刻度。实际上,这个图表将使用这四个范围的最大绝对值。我们可以用以下方法确定这个值:

var maxExtent = d3.max(
    xExtents.concat(yExtents), 
    function(d) { return Math.abs(d); 
});

现在我们已经准备好创建图表的属性,包括其宽度、高度以及代表点的圆的半径大小:

    var graphWidth = 400, graphHeight = 400;
    var radius = 5;

现在我们有了创建将数据映射到渲染位置所需的刻度所需的所有信息:

    var scale = d3.scale.linear()
        .domain([-maxExtent, maxExtent])
        .range([0, graphWidth]);

注意

这个示例(以及本章剩余的部分)将数据缩放,使得域是范围绝对值的负数和正数。简单来说,这个刻度确保了当渲染到正方形画布时,所有点都是可见的,并且沿X维度的任何特定距离代表与沿Y维度相同的数据值的变化。

渲染开始于创建主要的 SVG 元素:

    var svg = d3.select('body')
        .append('svg')
        .attr('width', graphWidth)
        .attr('height', graphHeight);

最后,我们创建一个指定半径的圆来代表每个点:

    svg.selectAll('circle')
        .data(data)
        .enter()
        .append('circle')
        .attr({
            cx: function(d) { return xScale(d.AAPL); },
            cy: function(d) { return yScale(d.MSFT); },
            r: radius,
            fill: 'steelblue'
        });
}); // closing the call to d3.csv

恭喜你,你已经创建了你的第一个散点图!

整理散点图

在前一个示例的图表中存在几个问题。首先,注意有一个圆被剪切到右侧边界。按照当前的代码,一个点,即最大范围的点,将有它面积的一半被剪切。这可以通过包括至少为圆半径一半的边距来轻松解决。

另一个问题是有很多圆重叠,这会混淆数据的视觉理解。在散点图中解决这个问题的常见方法是不在圆中使用实心填充,而是简单地使用轮廓。

最后一个问题,实际上是一个为了保持前一个示例简单而做出的决定,那就是不要有任何坐标轴。以下链接中的示例解决了这些关注点:

注意

bl.ock (6.2): goo.gl/4T1aGZ

前面的示例有以下的输出:

整理散点图

这个结果是一个更有效的散点图。我们可以理解之前被遮挡的点,坐标轴也给我们每个点的值提供了一个感觉。

代码的更改相对较小。除了使用我们在其他示例中看到的代码添加轴(包括为各种主要元素分组)以及调整主 SVG 元素的大小以适应这些轴之外,唯一的更改是在创建圆圈的方式上:

graphGroup.selectAll('circle')
    .data(data)
    .enter()
    .append('circle')
    .attr({
        cx: function(d) { return scale(d.X); },
        cy: function(d) { return scale(d.Y); },
        r: radius,
        fill: 'none',
        stroke: 'steelblue'
    });

添加网格线

如果我们的散点图有网格线,那么它将更加有效。在 D3.js 中添加网格线到图表的方式实际上是一个小技巧:网格线实际上是轴的刻度,刻度是图形的宽度和高度,其中轴的标签和主线都被隐藏了。

要在我们的图表中添加网格线,我们将创建两个额外的轴。水平网格线将通过创建一个位于右侧边距的左向轴来渲染。我们将设置这个轴的标签为空,并将轴的线隐藏。然后刻度的大小被设置为延伸到左侧边距的另一个轴。我们将执行一个类似的过程来创建垂直网格线,除了将底部轴放置在顶部边距。

注意

bl.ock (6.3): goo.gl/ZmrY4H

结果图如下所示:

添加网格线

与前一个示例的唯一区别是创建这些新轴的几行代码和一个用于样式化的函数:

var yGridlinesAxis = d3.svg.axis().scale(scale).orient("left");
var yGridlineNodes = svg.append('g')
    .attr('transform', 'translate(' + (margins.left + graphWidth)
                       + ',' + margins.top + ')')
    .call(yGridlinesAxis
          .tickSize(graphWidth + axisPadding, 0, 0)
          .tickFormat(""));
styleGridlineNodes(yGridlineNodes);

代码开始于创建一个左向轴,然后在一个被平移到右侧边距的组中渲染它。

我们不是简单地通过.call()传递轴对象,而是首先调用它的两个函数。第一个是.tickSize(),它设置刻度的大小以跨越将要渲染点的整个区域。调用.tickFormat("")通知轴标签应为空。

现在我们只需要对轴进行一点样式化。这是通过styleGridLineNodes()函数完成的:

function styleGridlineNodes(axisNodes) {
    axisNodes.selectAll('.domain')
        .attr({
            fill: 'none',
            stroke: 'none'
        });
    axisNodes.selectAll('.tick line')
        .attr({
            fill: 'none',
            'stroke-width': 1,
            stroke: 'lightgray'
        });
}

这设置了轴主线的填充和描边,使其不可见。然后使实际的刻度变为浅灰色。

垂直网格线是通过一个类似的过程创建的:

var xGridlinesAxis = d3.svg.axis().scale(scale).orient("bottom");
var xGridlineNodes = svg.append('g')
    .attr('transform', 'translate(' + margins.left + ',' + 
            (totalHeight - margins.bottom + axisPadding) + ')')
    .call(xGridlinesAxis
          .tickSize(-graphWidth - axisPadding, 0, 0)
          .tickFormat(""));
styleGridlineNodes(xGridlineNodes);

关于这个过程的最后一点是渲染的顺序:网格线,然后是轴,然后是点。这确保了这些元素中的每一个都出现在其他元素之上。最重要的是点要位于网格线和轴之上,但网格线也位于可见轴的后面是一种良好的实践。这给你在网格线上留出了一些微调的空间。

创建气泡图

气泡图帮助我们可视化三个或四个维度的数据。气泡图中的每个数据点不仅由用于与 X 轴和 Y 轴作图的两个值组成,还包括一个或两个额外的值,这些值通常通过不同大小的符号和/或颜色来表示。

为了演示气泡图,以下图像显示了我们的示例结果:

创建气泡图

此图表背后的数据是从世界银行的三个不同数据集中汇总而来的数据集。这些数据关联了 2013 年世界银行数据中所有国家的出生预期寿命与出生率。

此图表沿X轴绘制年龄,沿Y轴绘制出生率。一个国家的相对人口由圆圈的大小表示,圆圈的颜色代表由世界银行按类别划分的国家经济区域。

我们不会深入探讨这些数据。它们可在goo.gl/K3yuuy找到。

数据的前几行如下:

CountryCode,CountryName,LifeExp,FertRate,Population,Region
ABW,Aruba,75.33217073,1.673,102911,Latin America & Caribbean
AFG,Afghanistan,60.93141463,4.9,30551674,South Asia

注意

如果你想查看原始数据,可以使用以下链接:

示例的代码可在以下链接找到:

注意

bl.ock(6.4):goo.gl/KQJceE

示例从加载数据和转换数据类型开始:

var url = "https://gist.githubusercontent.com/d3byex/30231953acaa9433a46f/raw/6c7eb1c562de92bdf8d0cd99c6912048161c187e/fert_pop_exp.csv";
    var data = rawData.map(function(d) {
        return {
            CountryCode: d.CountryCode,
            CountryName: d.CountryName,
            LifeExp: +d.LifeExp,
            FertRate: +d.FertRate,
            Population: +d.Population,
            Region: d.Region
        }
    });

现在我们定义了几个变量来定义最小和最大气泡大小以及边距,我们将它们设置为最大气泡半径的一半:

var minBubbleSize = 5, maxBubbleSize = 50;
var margin = { left: maxBubbleSize/2, top: maxBubbleSize/2,
               bottom: maxBubbleSize/2, right: maxBubbleSize/2
};

此特定图表需要基于以下四个数据系列的三条线性尺度和一个顺序尺度:

var lifeExpectancy = data.map(function(d) { return d.LifeExp; });
var fertilityRate = data.map(function(d) { return d.FertRate; });
var population = data.map(function(d) { return d.Population; });
var regions = data.map(function(d) { return d.Region; });

X轴的尺度将从最小预期寿命到最大预期寿命:

var xScale = d3.scale.linear()
    .domain([d3.min(lifeExpectancy), d3.max(lifeExpectancy)])
    .range([0, graphWidth]);

Y轴的范围将从顶部的最大出生率到底部的0

var yScale = d3.scale.linear()
    .domain([d3.max(fertilityRate), 0])
    .range([0, graphHeight]);

每个气泡的大小代表人口,半径范围将从之前配置的最小值到最大值:

var popScale = d3.scale.linear()
    .domain(d3.extent(population))
    .range([minBubbleSize, maxBubbleSize]);

每个气泡的颜色将基于地区的值。为此,我们为每个独特的地区名称和 10 色分类尺度之间建立映射:

var uniqueRegions = d3.set(regions).values();
var regionColorMap = d3.scale.ordinal()
    .domain(uniqueRegions)
    .range(d3.scale.category10().range());

现在我们可以开始渲染视觉效果,从轴开始:

var yAxis = d3.svg.axis().scale(yScale).orient('left');
var yAxisNodes = svg.append('g')
    .attr('transform', 'translate(' + 
          (margin.left - axisPadding) + ',' + margin.top + ')')
    .call(yAxis);
styleAxisNodes(yAxisNodes);

var xAxis = d3.svg.axis().scale(xScale).orient('bottom');
var xAxisNodes = svg.append('g')
    .attr('transform', 'translate(' + margin.left + ',' +
          (totalHeight - margin.bottom + axisPadding) + ')')
    .call(xAxis);
styleAxisNodes(xAxisNodes);

最终任务是渲染气泡:

svg.append('g')
    .attr('transform', 'translate(' + margin.left + ',' +
                                      margin.top + ')')
    .selectAll('circle')
    .data(data)
    .enter()
    .append('circle')
    .each(function(d) {
        d3.select(this).attr({
            cx: xScale(d.LifeExp),
            cy: yScale(d.FertRate),
            r: popScale(d.Population),
            fill: regionColorMap(d.Region),
            stroke: regionColorMap(d.Region),
            'fill-opacity': 0.5
        });
    });

摘要

在本章中,我们汇集了创建散点图和气泡图的几个示例。你学习了使用轴来组织表示两个到四个不同维度的数据的技术,其中两个维度使用轴,然后使用颜色和点的大小作为另外两个。

在下一章中,我们将从动画开始。我们将从动画的基础开始,到本章结束时,我们将扩展本章的最终示例,并使用动画来表示一个额外的维度,第五维度——时间。

第七章。创建动画可视化

现在,我们将探讨使用 D3.js 转换来表示视觉信息下的变化。我们将从几个概念的示例开始,这些概念涉及使用 D3.js 动画化视觉元素从一个状态到另一个状态的性质。

到本章结束时,我们将从第六章,创建散点图和气泡图,扩展气泡可视化,以展示我们如何随着通过多年的数据移动来动画化我们的气泡。这将展示一个相对复杂的动画构建过程,用户可以轻松地从中推断出信息中的趋势。

在本章中,我们将通过以下示例涵盖以下主题:

  • 使用转换进行动画

  • 动画矩形填充颜色

  • 同时动画化多个属性

  • 延迟动画

  • 创建链式转换

  • 处理转换的开始和结束事件

  • 使用缓动改变文本的内容和大小

  • 使用定时器安排动画的步骤

  • 通过动画添加气泡图的第五维:时间

动画简介

D3.js 提供了广泛的动画化可视化能力。通过使用动画,我们可以为观众提供一种理解数据随时间变化的方式。

在 D3.js 中,动画全部关于随时间改变视觉对象的属性。当这些属性改变时,DOM 被更新,视觉被修改以表示新的状态。

为了动画化属性,D3.js 提供了以下能力,我们将对其进行检查:

  • 转换

  • 插值器和缓动

  • 缓动

  • 定时器

使用转换进行动画

D3.js 动画通过转换的概念实现。转换提供指令和信息给 D3.js,以便在特定的时间段内改变一个或多个视觉属性值。

当 D3.js 在一个视觉元素上开始转换时,它会计算正在转换的元素的初始样式和结束样式。这些通常被称为开始和结束关键帧。每个关键帧是一组你可以指定为动画一部分的样式和其他属性。然后 D3.js 将这些属性从起始值动画化到结束值。

动画矩形填充颜色

为了演示转换的实际操作,我们将从一个示例开始,并动画化矩形的颜色从一种颜色转换到另一种颜色。此示例的代码可在以下链接找到:

注意

bl.ock (7.1):goo.gl/oNJOQ9

在此示例中,我们首先创建以下 SVG 矩形,并将其初始 fill 设置为 red,然后在五秒内过渡到 blue 颜色。

运行示例时,您将看到一个矩形在五秒内从红色变为蓝色。在这段时间内,它平滑地通过中间颜色,如紫色,正如以下图像所示:

矩形填充颜色的动画

此代码的主要部分负责动画,它首先创建矩形并将其初始颜色设置为红色:

svg.append('rect')
    .attr({
        x: '10px',
        y: '10px',
        width: 80,
        height: 80,
        fill: 'red'
    })
    .transition()
    .duration(5000)
    .attr({ fill: 'blue' });

.transition() 调用通知 D3.js 我们想要过渡 rect 元素的一个或多个属性,这些属性是通过调用 .style() 或 .attr() 对 rect 元素的属性所做的更改。

.transition() 调用指示 D3.js 跟踪使用 .style().attr() 调用对 SVG 元素的属性所做的任何更改。

在这种情况下,我们指定矩形的 fill 在过渡结束时应该是 blue。D3.js 使用此信息来计算起始和结束关键帧,跟踪矩形上的填充应该从红色变为蓝色。

当这些元素的渲染开始时,D3.js 也会开始动画,并在指定期间平滑地改变填充属性。

同时动画多个属性

在过渡期间可以对对象上的多个属性进行动画。要完成此操作,只需在 .transition() 调用之后设置多个属性即可。

例如,以下代码在五秒内动画化矩形的定位和大小:

注意

bl.ock (7.2): goo.gl/2qG0EV

代码扩展了之前的示例,不仅动画化填充,还将位置改变为使矩形沿对角线移动,并修改大小,使矩形在过渡结束时宽度和高的一半:

svg.append('rect')
    .attr({
        x: 10,
        y: 10,
        width: 80,
        height: 80,
        fill: 'red'
    })
    .transition()
    .duration(5000)
    .attr({
        x: 460,
        y: 150,
        width: 40,
        height: 40,
        fill: 'blue'
    });

结果动画看起来如下所示,其中矩形沿着箭头的路径移动,同时改变颜色和大小:

同时动画多个属性

延迟过渡

如果您不想动画立即开始,可以使用延迟。延迟将过渡的开始延迟指定的时间。

以下示例将过渡的开始延迟一秒,然后运行四秒的过渡,总共完成五秒的过渡。

注意

bl.ock (7.3): goo.gl/Vyd6Pd

之前示例的代码与之前的代码相同,除了以下行:

.transition()
.delay(1000)
.duration(4000)

创建链式过渡

单个过渡只改变一组关键帧之间的属性。然而,可以将过渡链接起来,以提供多个动画序列。以下示例演示了两个过渡的链接(以及开始时的延迟)。

注意

bl.ock (7.4): goo.gl/IfYJmY

第一个转换执行了二秒钟,并使矩形的大小、颜色和位置动画化到 SVG 区域的中间。然后第二个转换将矩形移动到右上角,持续另外二秒钟,同时继续改变其颜色和大小。总的执行时间保持为五秒钟:

svg.append('rect')
    .attr({
        x: 10,
        'y': 10,
        width: 80,
        height: 80,
        fill: 'red'
    })
    .transition()
    .delay(1000)
    .duration(2000)
    .attr({
        x: 240,
        y: 80,
        width: 60,
        height: 60,
        fill: 'purple'
    })
    .transition()
    .duration(2000)
    .attr({
        width: 40,
        height: 40,
        x: 460,
        y: 10,
        fill: 'blue'
    });

创建链式转换

处理转换的开始和结束事件

使用 .each() 函数可以处理转换的开始和结束事件。这对于确保转换的开始或结束样式与您期望的完全一致非常有用。当插值器(下一节将介绍)达到精确的预期值时,这可能会成为一个问题,此时起始值直到动画运行时才知道,或者存在需要解决的浏览器特定问题。

注意

浏览器问题的例子之一是透明颜色被表示为 rgba(0,0,0,0)。这是黑色,但完全透明。然而,使用这种颜色进行的动画始终以完全不透明的黑色开始。可以使用开始事件来修复动画开始时的颜色。

以下示例通过修改前一个示例,展示了如何连接到第一个转换的开始事件和第二个转换的结束事件:

注意

bl.ock (7.5): goo.gl/746hLo

在此示例中,有两个基本的变化。第一个转换的开始事件的处理改变了矩形颜色为绿色。这导致矩形在延迟结束后从红色闪烁到绿色:

.each('start', function() {
    d3.select(this).attr({ fill: 'green' });
})

以下代码显示了第二个变化,它将矩形在第二个动画结束时变为黄色:

.each('end', function() {
    d3.select(this).attr({ fill: 'yellow' });
});

注意,当使用 .each() 函数时,被调用的函数会失去选择上下文,并且不知道当前项。我们可以通过调用 d3.select(this) 来恢复这一点,它将返回函数正在应用到的当前数据。

注意

根据我的经验,我发现转换前后属性的设置必须使用一致的符号。如果你在转换之前使用 .style(),然后在之后使用 .attr(),即使是在相同的属性上,该属性上的转换也不会工作。所以,如果你在 .transition() 之前使用 .style(),确保在之后使用 .style()(反之亦然对于 .attr())。

使用缓动改变文本的内容和大小

缓动为 D3.js 提供了一种在转换期间计算属性值的方式,而不需要 D3.js 跟踪关键帧。当动画化大量项目时,关键帧可能会成为性能问题,因此缓动在这种情况下非常有帮助。

缓动动画给了我们连接自己的插值器的机会,在动画的每个步骤中提供值。插值器是一个函数,它接收一个介于 0.0 和 1.0 之间的单个值,该值表示过渡完成的当前百分比。插值器的实现然后使用此值来计算该时间点的值。

我们将探讨两个缓动动画的示例。第一个示例可在以下链接中找到,它在一个十秒的周期内将文本项的值从 0 动画到 10:

注意

bl.ock (7.6): goo.gl/SlWBdp

这实际上是一些不能使用属性动画来完成的事情。我们必须调用 DOM 元素的.text()函数来设置文本,因此我们不能使用那种技术来动画化内容的改变。我们必须使用缓动动画。以下示例代码片段创建了一个在动画过程中设置文本内容的缓动动画:

svg.append('text')
    .attr({ x: 10, y: 50 })
    .transition()
    .duration(10000)
    .tween("mytween", function () {
        return function(t) {
            this.textContent = d3.interpolateRound(0, 10)(t);
        }
    });

.tween()的第一个参数只是这个缓动的名称。第二个参数是一个工厂函数,它返回一个函数给 D3.js,该函数将在过渡的每个步骤中被调用,传递给它过渡完成的当前百分比。

工厂函数在动画开始时为每个数据项调用一次。它返回的函数会被重复调用,并使用d3.interpolateRound()函数根据t的值返回介于 0 和 10 之间的舍入数字。

D3.js 提供了一些插值函数,例如:

  • d3.interpolateNumber

  • d3.interpolateRound

  • d3.interpolateString

  • d3.interpolateRgb

  • d3.interpolateHsl

  • d3.interpolateLab

  • d3.interpolateHcl

  • d3.interpolateArray

  • d3.interpolateObject

  • d3.interpolateTransform

  • d3.interpolateZoom

D3.js 还有一个函数d3.interpolate(a, b),它根据最终值b的类型,从上一个列表中返回适当的插值函数,使用以下算法:

  • 如果b是一个颜色,则使用interpolateRgb

  • 如果b是一个字符串,则使用interpolateString

  • 如果b是一个数组,则使用interpolateArray

  • 如果b是一个对象且不能转换为数字,则使用interpolateObject

  • 否则,使用interpolateNumber

要演示d3.interpolate()和一些底层智能,请打开以下示例:

注意

bl.ock (7.7): goo.gl/792lpH

此示例使用.styleTween()函数来改变文本样式的字体属性,在五秒内将字体大小从 12 px 增加到 36 px。

使用缓动动画改变文本内容和大小

svg.append("text")
    .attr({ x: 10, y: 50 })
    .text('Watch my size change')
    .transition()
    .duration(5000)
    .styleTween('font', function() {
        return d3.interpolate('12px Helvetica', '36px Helvetica');
    });

.styleTween() 函数的操作方式与 .tween() 类似,除了第一个参数指定了将被设置为通过工厂方法提供的插值函数返回的值的属性名称。还有一个 .attrTween() 函数,它执行相同的操作,但是在属性上而不是在样式上。

d3.interpolate() 函数足够智能,可以确定应该使用 d3.interpolateString(),并且可以识别出这两个字符串代表字体大小和名称,除了执行适当的插值。

定时器

D3.js 使用定时器来管理过渡,这些定时器内部安排代码在特定时间运行。这些定时器也对外公开供你使用。

使用 d3.timer(yourFunction, [delay], [mark]) 可以创建一个定时器,它接受一个要调用的函数、一个延迟和一个起始时间。这个起始时间被称为 mark,其默认值为 Date.now

D3.js 定时器不是以固定间隔执行——它们不是周期性定时器。定时器在 mark + delay 指定的时间开始执行。然后,D3.js 将尽可能频繁地调用该函数,直到它调用的函数返回 true

使用 markdelay 可以允许非常具体地声明启动执行的时间。例如,以下命令安排了一个在 2015 年 9 月 1 日之前四小时的事件:

d3.timer(notify, -4 * 1000 * 60 * 60, +new Date(2015, 09, 01));

要实现一次性定时器,只需在函数第一次调用时返回 true

关于定时器的最后一点,如果你想定期使用定时器来提醒你,通常更好的做法是使用 JavaScript 内置的 setInterval() 函数。我们将在下一节中检查定期使用定时器。

在气泡图中添加第五维——时间

现在让我们将我们所学到的关于动画的知识应用到一些真实数据上。我们将回顾第六章中关于创建散点图和气泡图的气泡图可视化,从单一年份(2013)的数据集扩展到所有可用的年份(1960 年至 2013 年)。我们将修改视觉呈现,使其定期更新并基于数据值的变化将气泡动画到新的位置和大小。

扩展的数据集可在goo.gl/rC5WS0找到。基本区别是包含了一个年份列,以及涵盖 54 年的数据。

示例的代码和演示可在以下链接找到:

注意

bl.ock (7.7): goo.gl/iYCNbG

当你运行这个命令时,你会看到数据随年份变化的平滑动画。显然,在像书籍这样的静态媒介中很难有效地展示这一点。但为了演示,我在每个十年开始时提供了可视化的截图,除了 2010 年,它被替换为 2013 年:

将第五维添加到气泡图 - 时间

随着年份的推进,所有国家都有向增加预期寿命以及降低生育率的方向发展的强烈趋势。不同国家的发展速度不同。但它确实给人一种强烈的印象,即某种正在发生的事情导致了这种效果。由于在气泡图中添加了时间这一额外维度,解读图表变得更加容易。

现在我们来检查这是如何在示例中实现的。大部分代码与第六章中的示例相同,即创建散点图和气泡图,它基于此示例。由于 URL 不同以及需要处理数据中的Year列,因此数据的加载和清洗略有不同:

var url = "https://gist.githubusercontent.com/d3byex/8fcf43e446b1e4dd0146/raw/7a11679cb4a810061dee660be0d30b6a9fe69f26/lfp_all.csv";
d3.csv(url, function (error, rawData) {
    var data = rawData.map(function (d) {
        return {
            CountryCode: d.CountryCode,
            CountryName: d.CountryName,
            LifeExp: +d.LifeExp,
            FertRate: +d.FertRate,
            Population: +d.Population,
            Region: d.Region,
            Year: d.Year
        };
    });

我们将逐个渲染每年的数据。作为这个过程的一部分,我们只需要提取每个特定年份的数据。我们可以以多种方式做到这一点。D3.js 提供了一个非常强大的函数来帮我们完成这个任务:d3.nest()。此函数将Year列旋转为关联数组的索引:

var nested = d3.nest()
    .key(function (d) { return d.Year; })
    .sortKeys(d3.ascending)
    .map(data);

我们可以使用数组语义(如nested[1975])访问特定年份的所有数据,这将给我们 1975 年的数据(只有行)。

注意

更多关于d3.nest()的信息,请参阅github.com/mbostock/d3/wiki/Arrays#-nest

代码在创建轴时保持一致。接下来新的代码片段是在图表上添加一个文本标签来显示数据所代表的年份。这个标签位于将要渲染气泡的区域左下角:

var yearLabel = svg.append('g')
    .append('text')
    .attr('transform', 'translate(40, 450)')
    .attr('font-size', '75');

然后创建一个组来包含气泡。渲染函数将在每次被调用时选择这个组:

var bubblesHolder = svg.append('g');

这标志着渲染和动画气泡的代码的开始。它首先声明了每个年份应该绘制的间隔(每秒 10 次):

var interval = 100;

由于气泡必须反复渲染,我们创建了一个函数,可以调用以仅渲染指定年份的气泡:

function render(year) {
    var dataForYear = nested[year];

    var bubbles = bubblesHolder
        .selectAll("circle")
        .data(dataForYear, function (datum) { 
             return datum.CountryCode; 
        });

    bubbles.enter()
        .append("circle")
        .each(setItemAttributes);

    bubbles
        .transition()
        .duration(interval)
        .each(setItemAttributes);

    bubbles.exit().remove();

    yearLabel.text(year);
};

此函数首先提取特定年份的行,然后将数据绑定到bubblesHolder组中的圆圈。对.data()的调用还指定了CountryCode将用作键。这非常重要,因为当我们从一年移动到另一年时,D3.js 将使用此键将现有的气泡映射到新数据,并基于此键做出决策,以确定如何进入-更新-退出圆圈。

下一个语句执行 enter 函数,创建新的圆圈并调用一个函数来设置圆圈的各个属性:

function setItemAttributes(d) {
    d3.select(this).attr({
        cx: xScale(d.LifeExp),
        cy: yScale(d.FertRate),
        r: popScale(d.Population),
        style: "fill:" + regionColorMap(d.Region) + ";" +
            "fill-opacity:0.5;" +
            "stroke:" + regionColorMap(d.Region) + ";"
    });
};

我们使用一个函数,因为代码也用它来更新。最后,偶尔会有一个国家从数据中消失,所以我们将删除任何在这种情况下的气泡。

我们需要做的最后一件事是执行时间动画。这是通过在指定间隔内迭代每个年份来完成的。为此,我们需要知道起始年份和结束年份,我们可以通过以下方式获得:

var minYear = d3.min(data, function (d) { return d.Year; });
var maxYear = d3.max(data, function (d) { return d.Year; });

这一步是设置一个变量来表示当前年份,并渲染该年份:

var currentYear = minYear;
render(currentYear);

现在我们创建一个由计时器调用的函数。这个函数返回另一个函数,它增加年份,如果年份小于最大年份,则再次调用渲染,然后安排另一个计时器实例以毫秒为间隔运行。这种模式有效地使用了一系列 D3.js 计时器来实现周期性计时器:

var callback = function () {
    return function () {
        currentYear++;
        console.log(currentYear);
        if (currentYear <= maxYear) {
            render(currentYear);
            d3.timer(callback(), interval);
        }
        return true;
    }
}

注意

注意,此代码每次调用时都返回true。这使得它成为一个一次性计时器。但在返回true之前,如果我们需要渲染另一年,我们将启动另一个计时器。

最后要完成的事情是启动计时器:

d3.timer(callback(), interval);

摘要

在本章中,你学习了 D3.js 中动画的基础知识,并在本章结束时,将这些简单概念应用于制作看似非常复杂的数据可视化。

我们从过渡的例子开始,使用它们在一段时间内将属性从一个状态动画到另一个状态,并将动画链接在一起。接下来,我们探讨了不使用关键帧处理动画的方法,即使用 tweening。我们还快速浏览了插值的概念。

我们通过检查计时器结束,然后应用本章的所有概念,逐步渲染大量数据,给可视化观看者一种通过动画时间来观察数据变化的感觉。

在下一章中,我们将探讨当用户与应用程序交互时更改视觉效果的技巧,学习基于交互事件的数据拖拽和过滤等概念。

第八章:添加用户交互

优秀的可视化不仅提供漂亮的图片和动画;它们允许用户与数据交互,赋予他们通过给定静态展示可能不明显的数据含义进行探索的能力。

优秀的交互允许用户在大量信息中找到自己的路径。它允许他们浏览单个显示无法容纳的数据,深入总结信息,并且可以放大以获得更高层次的视图——本质上,它允许用户从树木中看到森林。

同样非常有价值的是允许用户轻松选择、重新排序和重新定位视觉元素的能力。通过这些操作,用户可以通过鼠标悬停或触摸简单地查看数据的详细信息,重新排列项目以揭示其他见解,并且还可以看到数据在重新排序时的移动情况。这为用户提供了一种恒定的感觉,并显示了当需要重新排列时数据如何变化。

在本章中,我们将探讨为您的 D3.js 可视化添加交互性的多种技术。我们将探讨使用鼠标突出显示信息、提供上下文信息、平移和缩放您的可视化以及使用刷选来选择和缩放信息视图的概念。

具体来说,在本章中我们将学习以下主题:

  • 钩入 D3.js 可视化上的鼠标事件

  • 点击和响应鼠标事件

  • 构建多个视觉动画模型以提供交互反馈

  • 处理鼠标悬停以在特定视觉上提供详细信息

  • 创建对鼠标事件做出响应的流畅动画

  • 刷选及其在数据选择中的应用

  • 实现用于查看股票数据的上下文焦点交互模式

处理鼠标事件

鼠标是用户与 D3.js 可视化交互最常用的设备。触摸在平板电脑的情况下常用,在许多情况下,触摸事件可以映射到鼠标事件。在本章中,我们将专注于鼠标。但我们涵盖的大部分内容也适用于触摸。D3.js 还可以轻松支持触摸设备上的触摸概念,如捏合。

要在 D3.js 中处理鼠标事件,我们为希望处理事件的 SVG 元素附加事件监听器。使用.on()函数添加处理程序,该函数接受事件名称和当鼠标事件发生时要调用的函数作为参数。

我们将探讨处理四个鼠标事件:mousemovemouseentermouseoutclick

使用 mousemove 跟踪鼠标位置

鼠标在 SVG 可视化上的移动通过监听mousemove事件报告给您的代码。以下示例演示了跟踪和报告鼠标位置:

注意

bl.ock (8.1): goo.gl/VK67C4

var svg = d3.select('body')
    .append('svg')
    .attr({
        width: 450,
        height: 450
    });
var label = svg.append('text')
    .attr('x', 10)
    .attr('y', 30);

svg.on('mousemove', function () {
    var position = d3.mouse(svg.node());
    label.text('X=' + position[0] + ' , Y=' + position[1]);
});

我们使用 .on() 监听 mousemove 事件,在事件触发时传递它,并更新 SVG 文本元素中的文本内容:

使用 mousemove 跟踪鼠标位置

鼠标的位置不会作为参数传递给函数。为了获取实际的鼠标位置,我们需要调用 d3.mouse() 函数,并将其传递给 svg.node() 的返回值。然后该函数计算鼠标相对于鼠标正在移动的 SVG 元素的 XY 位置。

捕获鼠标进入和退出 SVG 元素

使用相应的 mouseentermouseout 事件捕获鼠标进入和退出特定 SVG 元素。以下示例通过创建几个圆并改变鼠标在圆区域内(也称为 悬停)时的颜色来展示这一点。

注意

bl.ock (8.2): goo.gl/4cfrdq

此代码创建了三个不同大小的圆(半径分别为 302040 像素):

var data = [30, 20, 40],

通过挂钩到这两个事件来跟踪鼠标的进入和退出:

    .on('mouseenter', function() {
        d3.select(this).attr('fill', 'red');
    })
    .on('mouseout', function() {
        d3.select(this).attr('fill', 'steelblue');
    });

当你运行此示例时,你会看到三个大小略有不同的 steelblue 圆圈,并且当你将鼠标悬停在任何一个上面时,它会变成 red

捕获鼠标进入和退出 SVG 元素

注意,当前鼠标正在进入或退出的 SVG 元素不会传递给函数,因此我们需要使用 d3.select(this) 来检索它们。

让用户知道他们已经点击了鼠标

当用户在鼠标上点击按钮时,可以使用 mouseclick 事件跟踪被点击的鼠标。以下链接中的代码演示了处理点击事件处理器:

注意

bl.ock (8.3): goo.gl/91rt4S

此代码向示例 8.2 中的代码添加了一个事件处理器,以捕获点击事件并弹出一个显示数据值及其在集合中位置的警告框:

.on('click', function(d, i) {
    alert(d + ' ' + i);
});

让用户知道他们已经点击了鼠标

这非常方便,因为你可以得到你点击的视觉背后的数据。无需保留视觉到数据的映射,只需查找即可。

使用行为来拖动、平移和缩放

鼠标事件通常需要组合起来创建更复杂的交互,如拖动、平移和缩放。通常,这需要大量的代码来跟踪 mouseentermousemovemouseexit 事件的序列。

D3.js 通过使用 行为 提供了一种更好的方式来实现这些交互。这些行为是通过 D3.js 本身处理鼠标事件的一组复杂的 DOM/SVG 交互。在某种程度上,行为在移动平台上的手势识别器功能上作用相似。

D3.js 目前提供了两种内置的行为:

  • 拖动:这跟踪相对于原点的鼠标或多点触控移动

  • Zoom: 在拖动或捏合时,此功能会发出缩放和平移事件

让我们考察一个实现拖动的示例,以及另一个也添加了平移和缩放功能的示例。

拖动

拖动是交互式可视化中的一种常见行为,允许用户通过鼠标或触摸移动视觉元素。以下示例演示了使用拖动行为:

注意

bl.ock (8.4): goo.gl/wxn6iN

上述示例渲染了四个圆圈,并允许您使用鼠标在 SVG 区域内移动它们,但同时也限制了移动,以确保圆圈完全位于 SVG 元素的视觉区域内:

Drag

代码首先计算圆圈的位置,并使用选择进行渲染。然后,使用以下代码实现拖动行为:

var dragBehavior = d3.behavior.drag()
                              .on('drag', onDrag);
circles.call(dragBehavior);

function onDrag(d) {
    var x = d3.event.x,
        y = d3.event.y;
    if ((x >= radius) && (x <= width - radius) &&
        (y >= radius) && (y <= height - radius)) {
        d3.select(this)
            .attr('transform', function () {
                return 'translate(' + x + ', ' + y + ')';
            });
    }
}

该行为是通过使用 d3.behavior.drag() 创建的。然后,此对象要求我们告知它我们感兴趣的是监听 drag 事件。您还可以指定 dragstartdragged 事件的处理器,以识别拖动行为的开始和完成。

接下来,我们需要通知 D3.js 将行为连接到 SVG 元素。这是通过在选择上使用 .call() 函数来完成的。正如我们在渲染坐标轴时看到的,我们指定的函数将由 D3.js 在渲染每个选定项目时调用。在这种情况下,这将是我们拖动行为,因此,此函数的实现可以为我们执行所有必要的拖动 SVG 元素的事件处理。

当用户拖动相关 SVG 元素时,我们的拖动行为事件处理程序就会被调用。此函数首先从 d3.event 对象中检索正在拖动的项目的新的 xy 位置。这些值在调用此函数之前由 D3.js 计算并设置。

目前所需的所有操作只是为相应的 SVG 元素设置一个新的变换,将其移动到新位置。此示例还检查圆圈是否仍然完全位于 SVG 元素内,并且只有在该条件为 true 时才设置新位置。

平移和缩放

平移和缩放是数据可视化中的两种常见技术。平移允许用户将整个视觉在屏幕上拖动。这可以揭示原本在视觉区域外渲染的视觉。平移的一个常见场景是将地图拖动以显示之前不可见的区域。

缩放允许您放大或缩小用户与视觉之间的感知距离。这可以用来放大小物品,或者缩放以查看太大或超出视觉显示范围的物品。

平移和缩放都通过相同的 D3.js 行为 d3.behavior.zoom() 实现。以下示例演示了其用法:

注意

bl.ock (8.5): goo.gl/tEY0hm

当运行此示例时,您不仅可以拖动圆圈,还可以拖动背景以同时移动所有圆圈(平移)并使用鼠标滚轮进行缩放:

平移和缩放

为了添加这些附加功能,对之前的示例进行了一些小的修改。这些修改从声明缩放行为开始:

var zoomBehavior = d3.behavior.zoom()
    .scaleExtent([0.1, 10])
    .on('zoom', onZoom);

初始缩放级别为1.0。调用to .scaleExtent()通知行为,它应该缩小到0.1,即原始尺寸的十分之一,并放大到10,即原始尺寸的 10 倍。此外,当发生缩放事件时,行为应调用onZoom()函数。

现在我们创建主 SVG 元素,并使用.call()将其缩放行为附加到它:

var svg = d3.select('body')
    .append('svg')
    .attr({
        width: width,
        height: height
    })
    .call(zoomBehavior)
    .append('g');

代码还向 SVG 元素添加了一个组元素,然后svg变量就引用了这个组。平移和缩放事件通过顶级 SVG 元素路由到我们的处理器,然后处理器设置此组上的平移和缩放因子,因此对圆圈产生效果。

现在我们只需要实现zoomIt函数:

function onZoom() {
    svg.attr('transform', 'translate(' + d3.event.translate + 
              ')' + 'scale(' + d3.event.scale + ')');
}

在行为调用此函数之前,它将d3.event.translate变量设置为表示整个视觉应该发生的平移范围。

d3.event.scale变量也由 D3.js 设置,以表示适当的缩放级别。在此示例中,这个范围从 0.1 到 10。

另一个小改动是在声明拖动行为的方式上。

var dragBehavior = d3.behavior.drag()
    .on("drag", onDrag)
    .on("dragstart", function() {
        d3.event.sourceEvent.stopPropagation();
    });

这样做是因为如果以这种方式修改,示例将出现问题。如果保持原样,平移和缩放行为以及拖动行为将相互冲突。当拖动圆圈时,svg元素也会平移,而它应该保持在原位。

通过处理dragstart事件并调用d3.event.sourceEvent.stopPropagation(),我们防止了在圆圈上的鼠标事件向上冒泡到svg元素并开始平移。问题解决!

增强条形图的交互性

现在让我们将我们关于鼠标事件处理所学的知识应用到创建交互式条形图。条形图上的鼠标事件可以为与图表交互的人提供有用的上下文信息。

示例数据将使用早期章节中使用的预期寿命与生育率数据集的简化版。此数据集仅使用拉丁美洲和加勒比海经济区域的数据,这些区域大约包含 35 个国家,数据年份为 2013 年。

示例中的条形图将代表长寿,顶部将标注国家代码,并垂直排列的文本表示实际的长寿值和完整的国家名称。示例将省略坐标轴和边距以保持简单。

此示例的代码和实时示例可在以下位置找到:

注意

bl.ock (8.6): goo.gl/8jb9Rn

当鼠标移到条形图上时,这种交互模式可以用来视觉上强调特定的条形。我们之前在应用于圆的mouseentermouseout事件中看到了这一点。这里,我们将用它来突出条形:

增强条形图的交互性

创建表示条形的矩形使用以下代码:

svg.selectAll('rect')
    .data(data)
    .enter()
    .append('rect')
    .attr({
        width: barWidth,
        height: 0,
        y: height
    })

在创建条形图之后,代码将mouseovermouseout事件连接起来。mouseover事件使垂直文本完全不透明,并将条形图颜色设置为橙色:

    .on('mouseover', function (d) {
        d3.select('text.vert#' + d.CountryCode)
          .style('opacity', maxOpacity);
        d3.select(this).attr('fill', 'orange');
    })

mouseout事件动画化并设置文本不透明度回到原始值,并开始动画将颜色恢复到原始色调。这个动画在移动到条形图上时给出了鼠标轨迹的视觉效果:

    .on('mouseout', function (d) {
        d3.select('text.vert#' + d.CountryCode)
          .style('opacity', minOpacity);
        d3.select(this)
          .transition()
          .duration(returnToColorDuration)
          .attr('fill', 'rgb(0, 0, ' + 
                         Math.floor(colorScale(d.LifeExp)) + ')');
    })

创建条形图的最后部分执行动画,使条形图增长并从黑色过渡到图形加载的最终颜色:

    .transition()
    .duration(barGrowDuration)
    .attr({
        height: function (d) { return yScale(d.LifeExp); },
        x: function (d, i) { return xScale(i); },
        y: function (d) {
            return height - yScale(d.LifeExp);
        },
        fill: function (d) {
            return 'rgb(0, 0, ' + 
                   Math.floor(colorScale(d.LifeExp)) + ')';
        }
    });

为了增强底层信息的展示,我们将在每个条形上放置两份数据:国家代码作为顶部水平文本,以及显示数据实际值和国家全名的垂直文本。以下代码创建水平文本:

svg.selectAll('text')
    .data(data)
    .enter()
    .append('text')
    .text(function (d) { return d.CountryCode; })
    .attr({
        x: function (d, i) { return xScale(i) + barWidth / 2; },
        y: height,
        fill: 'white',
        'text-anchor': 'middle',
        'font-family': 'sans-serif',
        'font-size': '11px'
    })
    .transition()
    .duration(barGrowDuration)
    .attr('y', function (d) { 
                      return height - yScale(d.LifeExp) + 
                                      horzTextOffsetY; });

垂直文本由以下代码创建:

svg.selectAll('text.vert')
    .data(data)
    .enter()
    .append('text')
    .text(function (d) { return d.LifeExp.toFixed(2) + ' ' +
                                d.CountryName; })
    .attr({
        id: function (d) { return d.CountryCode; },
        opacity: minOpacity,
        transform: function (d, i) {
            var x = xScale(i) + halfBarWidth – 
                    verticalTextOffsetX;
            var y = height - yScale(d.LifeExp) + 
                    verticalTextOffsetY;
            return 'translate(' + x + ',' + y + ')rotate(90)';
        },
        'class': 'vert',
        'font-family': 'sans-serif',
        'font-size': 11,
        'fill': 'white'
    });

使用画笔突出显示选定的项目

D3.js 中的画笔提供了用户通过允许使用鼠标选择一个或多个视觉元素(以及相关的数据项)与你的可视化进行交互的能力。

这是在探索性数据分析与可视化中的一个非常重要的概念,因为它允许用户轻松地钻入和钻出数据或选择特定的数据项进行进一步分析。

D3.js 中的画笔非常灵活,你如何实现它取决于你向用户展示的视觉化的类型。我们将查看几个画笔的示例,然后实现一个真实示例,让我们可以使用画笔来检查股票数据。

画笔的在线示例

要理解画笔,让我们首先看看互联网上的一些画笔示例。这些都是你可以去玩玩的网络上的示例。

以下画笔显示了使用矩形选择来选择画笔内部的数据的方法(bl.ocks.org/mbostock/4343214):

画笔的在线示例

这种画笔的另一个例子是bl.ocks.org/mbostock/4063663上的散点图矩阵画笔。这个例子值得注意的是你可以选择任何散点图上的点。然后应用程序会选择其他所有图上的点,以便在这些图上突出显示数据:

画笔的在线示例

以下示例演示了如何使用画笔在力导向网络可视化中选择一个点(bl.ocks.org/mbostock/4565798):

画笔的在线示例

注意

我们将在第十一章可视化信息网络中更详细地了解力导向网络可视化。

在使用画笔时,创建自定义画笔手柄是一个常见的场景。手柄为你提供了一种方式,可以自定义画笔边缘的渲染,从而为用户提供视觉提示。

作为自定义画笔的一个例子,以下代码创建半圆形作为手柄:bl.ocks.org/mbostock/4349545

你可以通过拖动任一手柄来调整画笔的大小,并通过拖动手柄之间的区域来重新定位它:

画笔的在线示例

在我们创建自己的画笔之前,最后一个画笔的例子如下,它演示了一个被称为“焦点+上下文”的概念:

画笔的在线示例

在这个例子中,画笔被绘制在较小的图形(上下文)之上。上下文图是静态的,显示了整个数据范围的总览。当画笔在上下文中改变时,较大的图形(焦点)会实时动画化,同时画笔被改变。

在下一节中,我们将探讨创建一个类似版本的图形,该图形利用金融数据,这是此类交互式可视化的常见领域。

实现焦点+上下文

现在我们来探讨如何实现焦点+上下文。以下我们将使用的示例将此概念应用于一系列股票数据:

注意

bl.ock (8.7): goo.gl/Niyc56

生成的图形将类似于以下图形:

实现焦点+上下文

顶部的图形是图表的焦点,表示我们正在检查的股票数据的详细信息。底部的图形是上下文,始终是整个数据系列的图表。在这个例子中,我们关注的是 2010 年初到 2012 年初的数据。

上下文区域支持画笔操作。你可以通过点击上下文图并拖动鼠标来创建新的画笔,选择画笔的范围。然后可以通过拖动来滑动画笔,并且可以通过拖动任一边界在左右两侧调整其大小。焦点区域将始终显示上下文选择的区域详情。

要创建这个可视化,我们将绘制两个不同的图形,因此,我们需要为每个图形布局垂直区域,并创建一个足够大的主要 SVG 元素来容纳两者:

var width = 960, height = 600;

var margins = { top: 10, left: 50, right: 50, 
                bottom: 50, between: 50 };

var bottomGraphHeight = 50;
var topGraphHeight = height - (margins.top + margins.bottom + margins.between + bottomGraphHeight);
var graphWidths = width - margins.left - margins.right;

这个示例还需要创建一个裁剪区域。当焦点区域的线条按比例缩放时,它可能会与y轴重叠在左侧。裁剪区域防止线条在轴上向左流动:

svg.append('defs')
    .append('clipPath')
    .attr('id', 'clip')
    .append('rect')
    .attr('width', width)
    .attr('height', height);

这个裁剪矩形在线条的样式中被引用。当线条被绘制时,它们将被裁剪到这个边界。我们将在检查用于样式化线条的函数时看到这是如何指定的。

现在我们添加两个组,将分别包含焦点和上下文图的渲染:

var focus = svg
    .append('g')
    .attr('transform', 'translate(' + margins.left + ',' + margins.top + ')');

var context = svg.append('g')
    .attr('class', 'context')
    .attr('transform', 'translate(' + margins.left + ',' +
            (margins.top + topGraphHeight + margins.between) + ')');

这个可视化需要一个y轴,两个x轴,以及每个轴的适当比例。这些是通过以下代码创建的:

var xScaleTop = d3.time.scale().range([0, graphWidths]),
    xScaleBottom = d3.time.scale().range([0, graphWidths]),
    yScaleTop = d3.scale.linear().range([topGraphHeight, 0]),
    yScaleBottom = d3.scale.linear()
                     .range([bottomGraphHeight, 0]);

var xAxisTop = d3.svg.axis().scale(xScaleTop)
                 .orient('bottom'),
    xAxisBottom = d3.svg.axis().scale(xScaleBottom)
                        .orient('bottom');
var yAxisTop = d3.svg.axis().scale(yScaleTop).orient('left');

我们将绘制两条线,因此我们创建了两个线生成器,每个线一个:

var lineTop = d3.svg.line()
    .x(function (d) { return xScaleTop(d.date); })
    .y(function (d) { return yScaleTop(d.close); });

var lineBottom = d3.svg.line()
    .x(function (d) { return xScaleBottom(d.date); })
    .y(function (d) { return yScaleBottom(d.close); });

在加载数据并实际渲染之前,我们需要做最后一件事情,就是使用d3.svg.brush()创建我们的画笔:

var brush = d3.svg.brush()
    .x(xScaleBottom)
    .on('brush', function brushed() {
        xScaleTop.domain(brush.empty() ? xScaleBottom.domain() : 
                                         brush.extent());
        focus.select('.x.axis').call(xAxisTop);
    });

这个前置片段通知画笔,我们想要使用在xScaleBottom中定义的比例在x值上画笔。画笔是事件驱动的,并将处理brush事件,每当画笔移动或调整大小时都会触发此事件。

最后,代码所做的最后一件主要事情是加载数据并建立初始可视化。你之前已经见过这段代码,所以我们不会一步一步地解释它。简而言之,它包括加载数据,设置比例的域,以及添加和绘制轴和线条:

d3.tsv('https://gist.githubusercontent.com/d3byex/b6b753b6ef178fdb06a2/raw/0c13e82b6b59c3ba195d7f47c33e3fe00cc3f56f/aapl.tsv', function (error, data) {
    data.forEach(function (d) {
        d.date = d3.time.format('%d-%b-%y').parse(d.date);
        d.close = +d.close;
    });

    xScaleTop.domain(d3.extent(data, function (d) { 
                  return d.date; 
    }));
    yScaleTop.domain(d3.extent(data, function (d) { 
        return d.close; 
    }));
    xScaleBottom.domain(d3.extent(data, function (d) { 
        return d.date; 
    }));
    yScaleBottom.domain(d3.extent(data, function (d) { 
        return d.close; 
    }));

    var topXAxisNodes = focus.append('g')
        .attr('class', 'x axis')
        .attr('transform', 'translate(' + 0 + ',' + 
                           (margins.top + topGraphHeight) + ')')
        .call(xAxisTop);
    styleAxisNodes(topXAxisNodes, 0);

    focus.append('path')
        .datum(data)
        .attr('class', 'line')
        .attr('d', lineTop);

    var topYAxisNodes = focus.append('g')
        .call(yAxisTop);
    styleAxisNodes(topYAxisNodes);

    context.append('path')
        .datum(data)
        .attr('class', 'line')
        .attr('d', lineBottom);

    var bottomXAxisNodes = context.append('g')
        .attr('transform', 'translate(0,' + 
                           bottomGraphHeight + ')')
        .call(xAxisBottom);
    styleAxisNodes(bottomXAxisNodes, 0);

    context.append('g')
        .attr('class', 'x brush')
        .call(brush)
        .selectAll('rect')
        .attr('y', -6)
        .attr('height', bottomGraphHeight + 7);

    context.selectAll('.extent')
        .attr({
            stroke: '#000',
            'fill-opacity': 0.125,
            'shape-rendering': 'crispEdges'
        });

    styleLines(svg);
});

恭喜!你已经完成了创建一个相当复杂的股票数据交互式显示的过程。但美的是,通过 D3.js 的底层功能,它由一系列相对简单的步骤组成,最终实现了美丽的数据

摘要

在本章中,你学习了如何使用 D3.js 提供的鼠标事件来创建交互式可视化。我们首先解释了如何连接鼠标事件并对其做出响应,随着事件的发生改变可视化。然后我们检查了行为,以及我们如何使用它们来实现拖动、平移和缩放,这使用户能够移动数据,更仔细地查看,以及进行放大和缩小。最后,我们介绍了画笔及其如何用于选择多个可视化/数据项,并以一个应用焦点 + 上下文来可视化财务数据的流畅示例结束。

在下一章关于布局的内容中,我们将稍微提升到 D3.js 的视觉堆栈中,以检查布局,布局本质上是为复杂数据可视化生成器。

第九章. 使用路径的复杂形状

在第三章中,我们简要探讨了路径的概念。我们了解到,我们可以通过创建一系列命令来使用路径及其相关的迷你语言创建多段渲染。虽然这些路径功能强大,但手动创建可能会很繁琐。

但不必担心,因为 D3.js 提供了一系列对象,只需几行 JavaScript 语句就可以创建复杂的路径。这些路径生成器在手动创建复杂路径时减轻了许多痛苦,因为它们自动组装命令序列。

此外,这本书中我们尚未探讨的一种重要类型的图表是折线图。这一部分内容是有意推迟到现在的,因为它是最常用于通过路径生成器创建线条的。在本章的示例之后,路径创建线条的能力将变得显而易见。

在本章中,我们将涵盖以下主题:

  • 路径数据生成器的概述

  • 线条和区域生成器

  • 弧线和饼图生成器

  • 符号生成器

  • 对角线和径向生成器

  • 线性插值器

路径数据生成器的概述

D3.js 不遗余力地使使用 SVG 变得简单,尤其是在创建复杂路径时。为此,D3 提供了一系列称为路径生成器的辅助函数,这些函数被创建来处理从一组数据中生成路径的繁琐细节。

我们将要探讨的生成器将遵循一种常见的使用模式,所以一旦你学会了如何使用一个,其他的使用方法就会自然而然地掌握。这些步骤包括:

  1. 创建generator对象。

  2. 指定可以用来找到XY值的访问器函数。

  3. 调用任何额外的函数来指定各种渲染指令。

  4. 将路径添加到视觉中。

  5. 在该路径上使用.datum()指定数据。

  6. 最后,将路径的d属性设置为生成器,这告诉路径在哪里找到该路径的generator对象。

一旦这些步骤完成,D3.js 渲染出视觉图形后,它将使用附加到d属性上的生成器,并根据你的数据创建路径命令。这也是为什么我们使用.datum()而不是.data()的原因,因为 datum 只将数据分配给单个元素,并不会强制在该数据上执行进入-更新-退出循环。

现在,让我们通过一些常见的生成器来探讨如何实现这一点——这将很有趣!

创建一系列线条

线生成器创建必要的命令来绘制一系列相互连接的线条:

注意

bl.ock (9.1): goo.gl/eAgBjL

之前的示例创建了一个单独的线路径生成器并将其渲染两次,结果如下图形:

创建一系列线条

路径生成器是通过以下数据和 d3.svg.line() 对象创建的。在该对象上,我们调用两个函数,x()y(),我们提供一个函数来告诉生成器如何为每个数据点定位 XY 值:

var data = [
    { X: 10, Y: 10 },
    { X: 60, Y: 60 },
    { X: 80, Y: 20 }
];
var generator = d3.svg.line()
    .x(function(d) { return d.X; })
    .y(function(d) { return d.Y; });

下一步是添加一个路径,调用其 .datum() 函数传递数据,并将 d 属性至少设置以指定要使用哪个生成器。示例创建了两个路径,它们使用相同的数据和生成器,但应用了不同的填充:

svg.append('path')
    .datum(data)
    .attr({
        d: generator,
        fill: 'none',
        stroke: 'steelblue'
    });
svg.append('path')
    .datum(data)
    .attr({
        transform: 'translate(100,0)',
        d: generator,
        fill: 'none',
        stroke: 'steelblue'
    });

此路径指定了两条线。线路径的默认操作是将最后一个点与第一个点连接并填充内部。在第一个路径的情况下,这是一个黑色填充,结果是一个黑色三角形(如果你放大,你会看到两边的 steelblue 轮廓)。后者路径将填充设置为空,所以结果只是两条线。

此示例还演示了使用单个路径生成器,但应用了转换和不同的样式到实际路径上。

检查生成的 SVG,我们看到 D3.js 创建了两个路径并自动生成了分配给路径的 d 属性的路径数据:

创建线条序列

区域

区域路径生成器允许我们创建填充特定颜色的线形图。这些的实际用途之一是创建面积图。以下示例演示了创建面积图:

注意

bl.ock (9.2): goo.gl/7Xmo7u

上述示例的结果看起来像以下图像。数据是随机的,所以每次运行都会不同:

区域

数据是通过生成 0 到 30 之间的 100 个随机数并定义 Y 为随机值以及 X 以 10 为增量增加来生成的:

var data = d3.range(100)
    .map(function(i) {
        return Math.random() * 30;
    })
    .map(function(d, i) {
            return { X: i * 10, Y: d }
    });

路径是通过使用 d3.svg.area() 对象生成的:

var generator = d3.svg.area()
    .y0(100)
    .x(function(d) { return d.X; })
    .y1(function (d) { return d.Y; });

区域路径生成器需要提供三个访问器函数:

  • x():这指定了获取 X 值的位置

  • y0():这获取区域基线的位置

  • y1():这检索给定 x() 值的高度

实际的 SVG 路径随后创建并样式化,类似于之前的示例。

创建弧、甜甜圈、楔形和段

弧是圆的一部分,它通过两个特定的角度扫过。通过 360 度完整扫过的弧实际上会形成一个圆。小于 360 度的扫过会给你一个圆的楔形,这通常被称为饼

使用 d3.svg.arc() 函数创建一个弧。此生成器接受四个参数,描述了弧的数学。楔形的大小是通过使用 .outerRadius() 函数和一个使用 .innerRadius() 指定的内半径来定义的。

以下示例使用弧来绘制圆形:

注意

bl.ock (9.3): goo.gl/fJN80J

Creating arcs, donuts, wedges, and segments

创建生成器的代码如下:

var generator = d3.svg.arc()
    .innerRadius(0)
    .outerRadius(60)
    .startAngle(0)
    .endAngle(Math.PI * 2);

生成器指定内半径为 0 和外半径为 60。起始和结束角度以弧度为单位,扫出一个完整的圆。

以下示例增加了内半径的大小来创建一个甜甜圈:

注意

bl.ock (9.4): goo.gl/NDVPRw

Creating arcs, donuts, wedges, and segments

代码中唯一的区别是调用 .innerRadius()

var generator = d3.svg.arc()
    .innerRadius(30)
    .outerRadius(60)
    .startAngle(0)
    .endAngle(Math.PI * 2);

我们现在已经创建了一个甜甜圈!那么,让我们来看一个创建饼形扇区的例子。我们可以通过指定内半径为 0 并设置起始角度和结束角度不扫满 360 度来创建一个饼形扇区,如下面的示例所示。

为了演示,以下示例创建了一个在 45105 度之间扫过的饼形扇区:

注意

bl.ock (9.5): goo.gl/cNizYk

Creating arcs, donuts, wedges, and segments

生成前一个饼形扇区的生成器如下:

var generator = d3.svg.arc()
    .innerRadius(0)
    .outerRadius(60)
    .startAngle(45 * Math.PI * 2 / 360)
    .endAngle(105 * Math.PI * 2 / 360);

对于弧的一个最终示例是,通过增加前一个示例的内半径使其大于 0 来创建一个扇区:

注意

bl.ock (9.6): goo.gl/24djAS

var generator = d3.svg.arc()
    .innerRadius(40)
    .outerRadius(60)
    .startAngle(45 * Math.PI * 2/360)
    .endAngle(105 * Math.PI * 2/360);

Creating arcs, donuts, wedges, and segments

Creating a pie chart

最常见的一种图表类型是饼图(它也是最被人诟病的一种)。饼图可以通过使用多个弧生成器并手动放置来创建。

为了使这个过程更简单,D3.js 提供了一个工具,帮助我们通过饼的生成器 d3.layout.pie() 来生成饼和相关的弧。从数据数组中,这个函数将生成一个弧规范数组,然后我们可以使用它来自动生成所有的饼形扇区。

因此,让我们来检查饼的创建过程:

注意

bl.ock (9.7): goo.gl/omVW2n

上述代码生成以下饼图:

Creating a pie chart

示例首先声明代表饼中每一块的数据值,然后将其传递给 d3.layout.pie() 函数:

 var data = [21, 32, 35, 64, 83];
 var pieSegments = d3.layout.pie()(data);

如果你检查 pieSegments 的内容,你会看到一系列类似于以下的对象:

[[object Object] {
  data: 21,
  endAngle: 6.283185307179587,
  padAngle: 0,
  startAngle: 5.721709173346517,
  value: 21
},
…
] 

我们可以通过一个弧生成器使用这些数据来生成饼:

var arcGenerator = d3.svg.arc()
    .innerRadius(0)
    .outerRadius(100)
    .startAngle(function(d) {
         return d.startAngle;
    })
    .endAngle(function(d) {
         return d.endAngle;
    });
var colors = d3.scale.category10();
group.selectAll('path')
    .data(pieSegments)
    .enter()
    .append('path')
    .attr('d', arcGenerator)
    .style('fill', function(d, i) {
        return colors(i);
  });

Exploding the pie

我们可以通过设置饼形扇区的边框宽度来制作一个爆炸式的饼。以下示例展示了这一过程。我们将跳过代码的详细说明,因为它只是在先前的示例中添加了 strokestroke-width

注意

bl.ock (9.8): goo.gl/fhQEau

Exploding the pie

Creating a ring graph

我们也可以很容易地将它转换成一个环形图,通过增加内半径,如下面的示例所示(对先前的示例进行简要修改):

注意

bl.ock (9.9): goo.gl/Mk60ws

创建环形图

创建符号

符号是可以在图表上使用的小形状,就像我们在散点图章节中使用小圆圈和正方形一样。D3.js 附带一个生成器可以创建六个符号:圆形、十字、菱形、正方形、向下三角形和向上三角形。

这些符号有名称,d3.svg.symbolTypes包含可用符号类型的名称数组。然后通过将符号名称传递给d3.svg.symbol().type()函数来创建一个符号,该函数返回指定符号的路径生成器。

在以下链接中可以找到渲染可用符号的示例:

注意

bl.ock (9.10): goo.gl/AM2ErM

以下代码渲染了以下符号作为结果:

创建符号

也许它们不是世界上最令人兴奋的事情,但它们对于在散点图上表示不同的数据项或在线图的点标记上很有用。

使用对角线创建曲线线条

对角线是我个人最喜欢的一种,它可以用在许多复杂的可视化中。这是一个我认为最好通过示例来理解的概念:

使用对角线创建曲线线条

对角线生成器生成一个点和一组其他点之间的曲线线条,根据目标点的位置生成适当的曲线。

以下示例创建了之前的图像:

注意

bl.ock (9.11): goo.gl/by9B4S

这个例子从定义源位置和目标位置使用 JavaScript 对象开始,然后,通过创建一个表示每个sourcetarget组合的对象数组:

var source = { x: 500, y: 50 };
var targets = [
    { x: 100, y: 150 },
    { x: 300, y: 150 },
    { x: 500, y: 150 },
    { x: 700, y: 150 },
    { x: 900, y: 150 }
];
var links = targets.map(function (target) {
    return { source: source, target: target };
});

然后,我们可以使用以下选择生成曲线线条,它使用一个d3.svg.diagonal()对象作为路径数据的生成器:

svg.selectAll('path')
    .data(links)
    .enter()
    .append('path')
    .attr({
        d: d3.svg.diagonal(),
        fill: 'none',
        stroke: '#ccc'
    });

圆形不是由对角生成器渲染的。代码根据源点和目标点的位置渲染和定位它们。

使用插值器绘制线图

现在我们来探讨如何使用内置的线生成器创建线图。在 D3.js 中渲染线条的功能非常强大,可以用来生成带有直线段的线条,或者使用多种不同的算法通过一系列点拟合曲线。

当使用线生成器渲染线条时,D3.js 会在你的数据上应用一个插值器,以确定如何创建连接数据点的路径段。以下表格列出了可用的线插值器:

插值器 操作
linear 点之间的直线
linear-closed 关闭线段,从最后一个点到第一个点,形成一个多边形
step-before 先垂直后水平进行步进绘制
step-after 先水平后垂直的步进绘制
basis 在端点处渲染带有控制点的 b 样条曲线
basis-open 在端点处渲染带有控制点的 b 样条曲线,不闭合环
basic-closed 在端点处渲染带有控制点的 b 样条曲线,闭合环
bundle basis等效,但带有张力参数
cardinal 一种基数样条,端点处有控制点
cardinal-open 一种基数样条,端点处有控制点;线可能不会与端点相交,但会通过内部点
cardinal-closed 将基数样条闭合成环
monotone 一种保持 y 值单调性的三次插值

默认情况下使用线性插值器,它本质上是在每对相邻点之间绘制一条直线。我们将逐一查看这些内容,因为我认为它们值得展示(而且很有趣!)。

示例可在以下链接找到:

注意

bl.ock (9.12): goo.gl/MdjuPz

应用程序向用户提供了两个选项。一个是选择插值类型,另一个是选择张力值,这个值仅在所选插值是bundle时使用。

然后示例生成一个由 8 个点表示的正弦波周期。作为一个例子,当选择线性插值时,结果如下:

使用插值器绘制线图

应用程序首先在 HTML 中创建下拉框和主要 SVG 元素。然后设置正弦波的刻度,将点映射到 SVG 中。当页面首次加载,以及每次更改插值或张力选择时,都会调用redraw()函数并生成图形。

redraw()函数从插值下拉菜单中检索当前值,并使用所选值创建线路径生成器:

var line = d3.svg.line()
    .interpolate(interpolation)
    .x(function(d) { return xScale(d[0]); })
    .y(function(d) { return yScale(d[1]); });

如果选择的插值是bundle,它还会检索所选的张力值并将其应用于线路径生成器:

if (interpolation === "bundle") {
    var tensionsSel = document.getElementById('tensions');
    var tension = tensionsSel.options[
        tensionsSel.selectedIndex].value;
    line.tension(tension);
}

然后使用路径和相关生成器生成线条,并在每个点的位置添加圆圈。

现在我们来检查这些插值如何渲染我们的正弦波。

线性与线性闭合插值器

线性插值器在点之间绘制直线:

线性与线性闭合插值器

线性闭合是一个略有变体的选项,它也会将最后一个点连接到第一个点:

线性与线性闭合插值器

步前和步后插值

展示步前和步后的最佳方式就是通过给出例子。但本质上,每对点都通过两条线连接,一条水平线和一条垂直线。

在“步前”模式下,垂直线先出现:

步前和步后插值

步后首先绘制水平线:

步长前和步长后插值

使用基插值创建曲线

基础曲线将通过端点,但可能不会通过内部点。内部点影响线的曲线,但线不必穿过任何内部点。

以下是一个基础插值的示例:

使用基插值创建曲线

基础开放插值不通过端点。它看起来与基础相似,但线不会在第一和第二点之间以及下一到最后一点之间绘制:

使用基插值创建曲线

我们为什么要这样做?这种情况是当第一个和最后一个点是控制点,并且可以在 XY 值中更改以影响曲线如何通过内部点。检查这一点超出了本书的范围,但我挑战您将您在 第八章 中学到的概念应用到实践中,允许用户拖动控制点,并看看这如何改变线的流动。

基础封闭告诉生成器关闭循环并确保循环在所有点上都是平滑的(代码的小变化再次省略)。结果是以下内容:

使用基插值创建曲线

非常棒!正如您所看到的,您可以使用这些插值器创建非常复杂的曲线形状。想象一下,如果您自己创建路径命令会怎样。我敢打赌,您会检查这条线的路径命令——有很多。

使用捆绑插值创建曲线

bundlebasis 类似,但您可以指定生成线张力的参数。张力允许您控制线将如何紧密地符合给定的点。要指定张力,将 .tension() 函数与介于 0.01.0(默认为 0.7)之间的参数值链式调用。以下显示了一个选定的张力值为 0.75

使用捆绑插值创建曲线

您可以看到生成的线(好吧,曲线)现在受点的影响要小得多。如果您将值设置为 0.0,这实际上将是一条直线。为了有效地展示其他张力值,以下表格展示了从 0.01.0 的张力各个点的形状变化:

张力 结果
0.0 使用捆绑插值创建曲线
0.5 使用捆绑插值创建曲线
1.0 使用捆绑插值创建曲线

如果你将1.0的张力与基函数插值进行比较,你会注意到它们是相同的。

使用基数插值创建曲线线

cardinal曲线类似于basis曲线,不同之处在于线条被强制通过所有点。以下图表展示了正常、开放和封闭的形式:

插值 结果
cardinal 使用基数插值创建曲线线
cardinal-open 使用基数插值创建曲线线
cardinal-closed 使用基数插值创建曲线线

摘要

在本章中,我们考察了使用 D3.js 路径数据生成器创建复杂形状的几种技术。我们从常见的生成器示例开始,包括线、区域、圆形、甜甜圈、弧线和对角线。这些工具非常强大,可以增强你轻松创建复杂可视化的能力。

我们通过考察线插值器结束了这一章,这是一种通知线路径生成器如何在数据点之间拟合线条的方法。这些插值,包括默认的线性插值,是高效创建复杂线图和适合数据的曲线形状的基础。

在下一章关于布局的部分,我们将稍微提升到 D3.js 的视觉堆栈中,来考察布局,布局本质上是为复杂数据可视化生成器。

第十章。使用布局可视化系列和层次数据

我们现在将探讨一些人认为的 D3.js 最强大的功能——布局。布局封装了检查你的数据并计算特定类型图表(如条形、面积、气泡、弦、树等)的视觉元素位置的算法。

我们将深入研究几个有用的布局。这些将根据数据的结构和可视化类型(如堆叠、层次、弦和基于流的图表)分为几个主要类别。对于每个类别,我们将介绍一些示例,包括数据和代码。

具体来说,我们将检查创建以下类型的图表和布局:

  • 使用堆叠布局创建条形图和面积图

  • 层次图包括树、簇树状图和围栏

  • 使用弦图表示项目之间的关系

  • 使用流图和桑基图流动数据

使用堆叠布局

堆叠是一类布局,它接受多个数据系列,其中每个系列中的每个测量值都渲染在彼此之上。这些非常适合展示每个系列在每个测量点上的测量值的比较大小。它们也非常擅长展示多个数据流在整个测量集中的变化。

堆叠图基本上归结为两种不同的表示:堆叠条形图和堆叠面积图。我们将检查这两种,并解释如何使用 D3.js 创建它们。

创建堆叠条形图

堆叠条形图的实现与条形图类似,但我们需要考虑每个条形的高度是由每个测量的总和组成的这一事实。通常,每个条形会被细分,每个部分的大小相对于总和,并且会赋予不同的颜色以区分它们。

让我们开始创建自己的堆叠条形图。将要使用的数据可以在goo.gl/6fJrxE找到。

以下文件的前几行。这些数据代表七个数据系列,每个系列代表一个特定的年龄范围,按州进行分类。每个值代表该年龄组给定州的居民人数。

State,Under 5 Years,5 to 13 Years,14 to 17 Years,18 to 24 Years,25 to 44 Years,45 to 64 Years,65 Years and Over
AL,310504,552339,259034,450818,1231572,1215966,641667
AK,52083,85640,42153,74257,198724,183159,50277
AZ,515910,828669,362642,601943,1804762,1523681,862573

在以下链接中可以找到在线示例:

注意

bl.ock (10.1): goo.gl/G3BIL7

结果条形图如下:

创建堆叠条形图

数据使用d3.csv()加载:

var url = 'https://gist.githubusercontent.com/d3byex/25129228aa50c30ef01f/raw/17838a0a03d94328a529de1dd768e956ce217af1/stacked_bars.csv';
d3.csv(url, function (error, data) {

检查结果数据中的第一个对象,我们看到以下结构:

创建堆叠条形图

此数组有 51 个元素,每个元素代表美国和华盛顿特区的每个州。这些数据需要转换成一个结构,为我们提供渲染每个条形以及每个条形内每个序列的矩形的信息。为此,我们需要进行三个步骤,最后一个步骤使用d3.layout.stack()

首先,代码提取每个数据序列的唯一键,即年龄组。这可以通过过滤掉数组中每个对象的非State属性的所有属性来实现。

var keys = d3.keys(data[0])
    .filter(function (key) {
        return key !== "State";
    });

创建堆积条形图

使用这些键,我们可以重新组织数据,以便我们有一个表示每个年龄组值的数组:

var statesAndAges = keys.map(function (ageRange) {
    return data.map(function (d) {
        return {
            x: d.State,
            y: +d[ageRange]
        };
    });
});

statesAndAges变量现在是一个包含七个元素的数组,每个元素都是一个对象数组,代表每个序列的xy值:

创建堆积条形图

现在,使用这些键,我们创建一个d3.layout.stack()函数,并让它处理这些数据。

var stackedData = d3.layout.stack()(statesAndAges);

此数据的堆叠结果是将堆叠函数添加一个额外的属性y0到每个序列中的每个对象。y0的值将是之前编号较低的序列中y值的总和。为了演示,以下是在每个数组第一个对象中的对象值:

创建堆积条形图

第一个对象中y0的值为0。第二个对象中y0的值为310504,等于第一个对象的y0 + y。第三个对象中y0的值为第二个的y0 + y,即862843。此函数已堆叠y值,每个y值都是将要渲染的条形单个段的y值。

数据现在已组织好以渲染条形图。下一步是创建主 SVG 元素:

var width = 960, height = 500;
var svg = d3.select('body')
    .append("svg")
    .attr({
        width: width,
        height: height
    });

接下来,代码计算xy比例尺,以将条形映射到指定的像素数。y比例尺的域将从0到所有序列中y0y的最大总和:

var yScale = d3.scale.linear()
        .domain([0,
            d3.max(stackedData, function (d) {
                return d3.max(d, function (d) {
                    return d.y0 + d.y;
                });
            })
        ])
        .range([0, height]);

x比例尺设置为每个州的序数rangeRoundBands

var xScale = d3.scale.ordinal()
    .domain(d3.range(stackedData[0].length))
    .rangeRoundBands([0, width], 0.05);

代码随后为每个序列创建一个组,并为每个组分配一个颜色,该颜色将用于填充矩形:

var colors = d3.scale.ordinal()
    .range(["#98abc5", "#8a89a6", "#7b6888",
            "#6b486b", "#a05d56", "#d0743c", "#ff8c00"]);

var groups = svg.selectAll("g")
    .data(stackedData)
    .enter()
    .append("g")
    .style("fill", function (d, i) {
        return colors(i);
    });

最后一步是渲染所有矩形。以下是在每个组内创建51个矩形的操作:

groups.selectAll("rect")
    .data(function (d) { return d; })
    .enter()
    .append("rect")
    .attr("x", function (d, i) {
        return xScale(i);
    })
    .attr("y", function (d, i) {
        return height - yScale(d.y) - yScale(d.y0);
    })
    .attr("height", function (d) {
        return yScale(d.y);
    })
    .attr("width", xScale.rangeBand());
});

就这样!您已经使用这些数据绘制了这个图表。

将堆积条形图修改为堆积面积图

堆积面积图与堆积条形图对数据的展示方式不同。要创建堆积面积图,我们需要将每个数据序列的渲染方式改为路径。路径是通过一个面积生成器定义的,该生成器在底部具有y值,在顶部具有y0 + y的总和。

堆积面积图的代码可在以下链接在线获取:

注意

bl.ock (10.2): goo.gl/PRw8wv

此示例的结果如下:

将堆叠条形图修改为堆叠面积图

与前一个示例相比,这种变化相对较小。数据加载和组织方式完全相同。刻度和颜色也是以相同的方式创建的。

差异在于视觉效果的呈现。我们不是渲染矩形组,而是为每个系列渲染一个填充路径。以下创建这些路径并为每个分配颜色:

svg.selectAll("path")
    .data(stackedData)
    .enter()
    .append("path")
    .style("fill", function (d, i) {
        return colors(i);
    });

这已经生成了路径元素,但尚未分配路径的d属性以创建实际的路径数据。这是我们下一步要做的事情,但首先我们需要创建一个面积生成器,将我们的数据转换为路径所需的数据。这个面积生成器需要指定三个值,即x值,y0(表示区域的底部),以及y1(位于区域的顶部):

var area = d3.svg.area()
    .x(function (d, i) {
        return xScale(i);
    })
    .y0(function (d) {
        return height - yScale(d.y0);
    })
    .y1(function (d) {
        return height - yScale(d.y + d.y0);
    });

最后,我们选择我们刚刚创建的路径并将它们绑定到每个适当的系列上,将对应路径的d属性设置为调用面积生成器的结果。请注意,这是为每个系列调用面积生成器:

svg.selectAll("path")
    .data(stackedData)
    .transition()
    .attr("d", function (d) {
        return area(d);
    });

将面积图转换为扩展面积图

存在一种堆叠面积图的变体,称为扩展面积图。扩展面积图完全填充图表的整个区域,可以用来轻松地可视化每个系列在每个点所代表的相对百分比。

这种图表是通过将所有系列中每个点的数据归一化到 1.0 来从堆叠面积图创建的。以下示例演示了这是如何执行的:

注意

bl.ock (10.3): goo.gl/g9BH4L

结果图表如下:

将面积图转换为扩展面积图

这从视觉上很好地展示了每个年龄组在期间相对大小如何变化。大多数情况下,年龄组的比例保持不变,除了数据末尾可能的一个州。

将堆叠面积图转换为扩展面积图是一件非常容易的事情。为了完成这个任务,我们需要做两件事。其中之一是改变我们堆叠数据的方式。我们将堆叠操作更改为以下内容:

var stackedData = d3.layout.stack()
    .offset('expand')(statesAndAges);

这里的变化是添加对.offset("expand")的调用。这通知 D3.js 将每个点的结果归一化到[0, 1]。默认偏移量是"zero",正如我们所看到的,它从*Y*值开始为0并执行运行总和。

数据现在已准备好,第二个更改是将Y轴范围更改为考虑域为[0, 1]

var yScale = d3.scale.linear()
    .domain([0, 1])
    .range([0, height]);

现在你已经有了你的扩展面积图。

展示分层数据

分层布局显示具有分层性质的信息。这可能是一个稍微递归的定义,但基本思想是某些数据项在较低级别分解为零个或多个数据项,然后可能到另一个级别,依此类推,直到所需的级别。

层次布局都是通过d3.layout.hierarchy()函数创建的,但该函数有专门化的版本,可以创建各种布局,这些布局属于常见的视觉模式,如树、簇和包围与包装。我们将查看每种类型布局的示例。

树形图

树形图本质上是一种节点-链接图。在第九章中,我们看到了使用一种称为对角线生成器的路径生成器的应用。这种生成器能够创建可以连接一个节点到一个或多个节点的曲线线段。为了帮助您回忆,我们有一个生成以下内容的示例:

树形图

这是一个基本的节点-链接图。树形图利用对角线并将它们应用于多个层次。该图可以结构化为树形或其他更复杂的布局,如辐射簇(我们将对其进行研究)。布局将计算节点的位置,然后我们需要渲染节点和连接的对角线。

我们将从一个简单的树形图开始。数据可在goo.gl/mcdT9r找到。数据的内容如下:

{
  "name": "Mike and Marcia",
  "children": [
    {
      "name": "Children",
      "children": [
        { "name": "Mikael" }
      ]
    },
    {
      "name": "Pets",
      "children": [
        {
          "name": "Dogs",
          "children": [
            { "name": "Bleu" },
            { "name": "Tagg" }
          ]
        },
        {
          "name": "Cats",
          "children": [
            { "name": "Bob" },
            { "name": "Peanut" }
          ]
        }
      ]
    }
  ]
}

示例可在以下位置找到:

注意

bl.ock (10.4): goo.gl/t1hBTS

渲染的结果是以下树形图:

树形图

我们的示例从加载数据、为图表建立度量标准、创建主要 SVG 元素以及建立主要组和边距开始:

var url = 'https://gist.githubusercontent.com/d3byex/25129228aa50c30ef01f/raw/c1c3ad9fa745c42c5410fba29cefccac47cd0ec7/familytree.json';
d3.json(url, function (error, data) {
    var width = 960, height = 500,
        nodeRadius = 10,
        margin = {
            left: 50, top: 10,
            bottom: 10, right: 40
        };

    var svg = d3.select('body')
        .append("svg")
        .attr({
            width: width,
            height: height
        });
    var mainGroup = svg.append("g")
        .attr("transform", "translate(" + margin.left + "," + 
                                          margin.top + ')');

为了将数据转换为树的可视表示,我们将使用d3.layout.tree()函数创建树布局。

var tree = d3.layout.tree()
    .size([
        height - (margin.bottom + margin.top),
        width - (margin.left + margin.right),
    ]);

这告诉 D3.js 我们想要创建一个树,它将数据映射到由heightwidth指定的矩形中。请注意,height是在width之前指定的。

图表有两个视觉组件:节点,由圆圈表示,以及边,即对角线。为了计算节点,我们使用布局的.nodes()函数,并将我们的数据传递给它。

var nodes = tree.nodes(data);

树函数寻找具有children属性的顶级节点。它将遍历层次结构中的所有节点,并确定其深度,在这种情况下,有四个级别。然后它将为每个节点添加xy属性,这些属性代表基于布局和特定widthheight计算出的节点位置。

检查nodes变量的内容,我们可以看到 D3.js 已经为我们提供了每个节点的位置(以下显示了前两个节点):

树形图

要获取树中的链接,我们调用tree.links(nodes)

var links = tree.links(nodes);

下图显示了导致此示例的链接:

树形图

新创建的数据结构由每个链接的元素组成,其中每个对象包含一个指向链接每端节点的sourcetarget属性。

我们现在有了创建视觉数据的数据准备就绪。接下来是创建对角线生成器的语句。我们使用.projection()函数,因为我们需要告诉生成器如何从每个数据中找到xy值:

var diagonal = d3.svg.diagonal()
    .projection(function(d) {
        return [d.y, d.x];
    });

现在我们可以创建对角线,为每个对角线重用生成器。对角线是在节点之前创建的,因为我们希望节点在前:

mainGroup.selectAll('path')
    .data(links)
    .enter()
    .append('path', 'g')
    .attr({
        d: diagonal,
        fill: 'none',
        stroke: '#ccc',
        'stroke-width': 2
    });

现在代码创建圆和标签。我们将用包含一个圆和一段文本的组来表示每个节点。以下创建这些组并将它们放置在计算出的位置:

var circleGroups = mainGroup.selectAll('g')
    .data(nodes)
    .enter()
    .append('g')
    .attr('transform', function (d) {
        return 'translate(' + d.y + ',' + d.x + ')';
    });

接下来,我们将圆作为每个节点组元素的子元素添加:

circleGroups.append('circle')
    .attr({
        r: nodeRadius,
        fill: '#fff',
        stroke: 'steelblue',
        'stroke-width': 3,
    });

然后我们将节点标签文本添加到组中:

circleGroups.append('text')
    .text(function (d) {
        return d.name;
    })
    .attr('y', function (d) {
        return d.children || d._children ?
            -nodeRadius * 2 : nodeRadius * 2;
    })
    .attr({
        dy: '.35em',
        'text-anchor': 'middle',
        'fill-opacity': 1
    })
    .style('font', '12px sans-serif');

当分配y属性时,如果节点不是叶子节点,则将文本位置偏移到圆上方;如果是叶子节点,则位于节点下方。

创建聚类树状图

一个层次结构也可以被可视化为一个称为聚类树状图的树形变体。聚类树状图与树图的不同之处在于我们使用聚类布局。这种布局将树的根放在中心。计算数据深度,并将相应数量的同心圆层放入图中。然后,将每个深度的节点放置在圆的边缘,以对应其深度。

为了演示这一点,我们将利用位于goo.gl/t3M7n1的数据。这些数据代表三个级别的数据,有一个根节点和第二级上的四个节点;这些节点中的每一个都有九个孩子。

以下是一些数据的样本:

{
  "name": "1",
  "children": [
    {
      "name": "1-1",
      "children": [
        { "name": "1-1-1" },
        { "name": "1-1-2" },
        { "name": "1-1-3" },
        { "name": "1-1-4" },
        { "name": "1-1-5" },
        { "name": "1-1-6" },
        { "name": "1-1-7" },
        { "name": "1-1-8" },
        { "name": "1-1-9" }
      ]
    },
    {
      "name": "1-2",
      "children": [
        { "name": "1-2-1" },
        { "name": "1-2-2" },
        { "name": "1-2-3" },
...

示例可在以下位置找到:

注意

bl.ock (10.5): goo.gl/cQtPuH

结果图如下:

创建聚类树状图

让我们逐步了解这是如何创建的。代码与树示例类似,但有一些不同。在数据加载后,创建主 SVG 元素,然后在元素内放置一个组:

var center = width / 2;
var mainGroup = svg.append('g')
    .attr("transform", "translate(" + center + "," + 
                                      center + ")");

布局算法将在中心点(0, 0)周围计算点,因此我们将组居中以便图形居中。

然后使用d3.layout.cluster()创建布局:

var cluster = d3.layout.cluster()
    .size([
        360,
        center - 50
    ]);

大小指定了两件事;第一个参数是点在圆外圈上扫过的角度数。这指定了360度,以便我们完全填满外圈。第二个参数是树深度,或者说外圈的最外层圆的半径。

接下来,我们使用布局来计算节点和链接的位置:

var nodes = cluster.nodes(data);
var links = cluster.links(nodes);

值得检查这些计算结果的前几个节点:

创建聚类树状图

xy 属性指定了节点(以及边)放置的方向和距离。x 属性指定了与垂直方向的夹角,而 y 属性的值指定了距离。

对角线是使用径向对角线计算的,需要将 x 值转换为弧度:

var diagonal = d3.svg.diagonal.radial()
    .projection(function(d) {
        return [
            d.y,
            d.x / 180 * Math.PI
        ];
    });

现在,我们可以使用连接节点的对角线径向生成器:

mainGroup.selectAll('path')
    .data(links)
    .enter()
    .append('path')
    .attr({
        'd': diagonal,
        fill: 'none',
        stroke: '#ccc',
        'stroke-width': 2
    }); 

接下来,我们创建一个组来包含节点和文本。这个技巧在于我们需要将组转换并旋转到正确的位置:

var nodeGroups = mainGroup.selectAll("g")
    .data(nodes)
    .enter()
    .append("g")
    .attr("transform", function(d) {
        return "rotate(" + (d.x - 90) + ")translate(" + d.y + ")";
    }); 

我们将组绕计算出的角度旋转 90 度。这改变了文本的流向,使其从圆圈流出,沿着对角线。请注意,旋转使用的是度数,而不是弧度,正如径向生成器所要求的。平移仅使用 y 值,它将组沿指定角度移动这个距离。现在我们将圆圈添加到组中:

nodeGroups.append("circle")
    .attr({
        r: nodeRadius,
        fill: '#fff',
        stroke: 'steelblue',
        'stroke-width': 3
    });

最后,我们添加文本。注意围绕文本的小计算,它表示角度大于或小于 180 度。这本质上意味着图左侧的节点位置是文本的末端对着节点,而在右侧,从文本的开始处开始。文本还通过圆半径的两倍进行变换,以防止它与圆重叠:

nodeGroups.append('text')
    .attr({
            dy: '.31em',
            'text-anchor': function(d) {
                return d.x < 180 ? 'start' : 'end';
            },
            'transform': function(d) {
                return d.x < 180 ? 
                           'translate(' + (nodeRadius*2) + ')' :
                           'rotate(180)' +
                           'translate(' + (-nodeRadius*2) + ')';
            }
        })
        .style('font', '12px sans-serif')
    .text(function(d) { return d.name; });

使用封装图表示层次结构

封装图使用视觉嵌套来表示层次结构。每个叶节点圆圈的大小揭示了每个数据点的定量维度。包含的圆圈显示了每个子树的近似累积大小,但请注意,由于空间浪费,不同级别之间存在一些扭曲。因此,只有叶节点可以准确比较。

以下是在线示例的位置:

注意

块 (10.6): goo.gl/MQ3CwG

以下图像是生成的视觉结果:

使用封装图表示层次结构

示例中使用的数据可在 goo.gl/RzvlV3 获取。它的结构类似于前面的示例,除了每个节点都添加了一个 value 属性。叶节点的值在它们的父节点中汇总,一直重复到顶部。

实质上,这些数据是对值的汇总,就像从销售人员到办公室、到部门、到公司层面的销售额汇总时所执行的操作一样。然后,图表使我们能够看到叶节点中数字的相对大小,这些节点被涂成橙色,并且可以了解树中每个级别的总数。

现在,让我们看看这是如何创建的。示例从加载数据开始,然后创建一个指定直径的 SVG 元素。然后创建一个包装布局,它也使用了直径。创建的层次气泡将被测量以适应指定的直径:

var pack = d3.layout.pack()
    .size([diameter, diameter])
    .value(function (d) { return d.value; });

现在我们渲染圆圈。对于每个节点,我们添加一个平移到适当位置的组,然后添加一个圆圈,其半径设置为计算出的半径(d.r)fillfill-opacitystroke根据节点是否为叶子节点而设置不同的值:

var nodes = svg.datum(data)
    .selectAll('g')
    .data(pack.nodes)
    .enter()
    .append('g')
    .attr('transform', function (d) {
        return 'translate(' + d.x + ',' + d.y + ')';
    });

nodes.append('circle')
    .each(function (d) {
        d3.select(this)
            .attr({
                r: d.r,
                fill: d.children ? 'rgb(31, 119, 180)' :
                                   '#ff7f0e',
                'fill-opacity': d.children ? 0.25 : 1.0,
                stroke: d.children ? 'rgb(31, 119, 180)' : 'none'
            });
    });

最后一步是将文本添加到叶子圆圈(没有子节点的那些,如使用过滤器指定的):

nodes.filter(function(d) {
        return !d.children;
    })
    .append('text')
    .attr('dy', '.3em')
    .style({
        'text-anchor': 'middle',
        'font': '10px sans-serif'
    })
    .text(function(d) {
        return d.name.substring(0, d.r / 3);
    });

使用弦图表示关系

弦图展示了实体组之间的关系。为了展示,我们将使用以下链接中的示例:

注意

bl.ock (10.7): goo.gl/8mRDSg

结果图表如下:

使用弦图表示关系

此示例中的数据是一个方阵数据,行和列代表发色(黑色、金色、棕色和红色)。数据代表总共100000次测量,其中每一行展示了具有特定发色的人所偏好的其他发色的总计数:

偏好
拥有 黑色
黑色 11975
金色 1951
棕色 8010
红色 1013
总计 22949

为了解释这个图表,每个外环段代表具有特定发色的人数。这些环段的尺寸与具有特定发色的人数的百分比成比例。从给定颜色的环段到另一个环段(或自身)的每个弧线代表喜欢该弧线另一侧发色的人数,反之亦然。每个环段外部的刻度给出了所代表的人数的总体感觉。

现在我们逐步创建此图。首先,我们创建我们的顶级 SVG 元素。主组被平移到中心,因为位置将围绕(0, 0)进行居中:

var width = 960, height = 500;
var svg = d3.select('body')
    .append('svg')
    .attr({
        width: width,
        height: height
    });
var mainGroup = svg.append('g')
    .attr('transform', 'translate(' + width / 2 + ',' + 
                                      height / 2 + ')');

现在我们声明数据。我们将使用硬编码的数组而不是从文件中读取。这些值代表之前表格中的值,不包括总计:

var matrix = [
    [11975, 5871, 8916, 2868],
    [1951, 10048, 2060, 6171],
    [8010, 16145, 8090, 8045],
    [1013, 990, 940, 6907]
];

然后我们使用d3.layout.chord()函数来创建此图的布局对象。

var layout = d3.layout.chord()
    .padding(.05)
    .matrix(matrix);

.padding(0.05)表示在图表外部区域的部分之间将有0.05弧度的空间,而.matrix()的调用指定了要使用的数据。

以下行代码创建了将要使用的颜色(黑色、金色、棕色和红色):

var fill = d3.scale.ordinal().domain(d3.range(4))
    .range(['#000000', '#FFEE89', '#957244', '#FF0023']);

然后,渲染环段。环段的内半径和外半径是视觉的最小维度的百分比。绑定的是布局对象的组属性。对于这些中的每一个,我们使用弧生成器渲染一个路径:

var innerRadius = Math.min(width, height) * 0.41,
    outerRadius = innerRadius * 1.1;
mainGroup.append('g')
    .selectAll('path')
    .data(layout.groups)
    .enter()
    .append('path')
    .style('fill', function(d) { return fill(d.index); })
    .style('stroke', function(d) { return fill(d.index); })
    .attr('d', d3.svg.arc()
        .innerRadius(innerRadius)
        .outerRadius(outerRadius));

接下来,渲染弦。将对每个数据项应用 d3.svg.chord() 函数,并生成大小为 innerRadius 的路径:

mainGroup.append('g')
    .selectAll('path')
    .data(layout.chords)
    .enter()
    .append('path')
    .attr('d', d3.svg.chord()
                 .radius(innerRadius))
    .style('fill', function(d) { return fill(d.target.index); })
    .style({
        opacity: 1,
        stroke: '#000',
        'fill-opacity': 0.67,
        'stroke-width': '0.5px'
    });

到目前为止,我们已经创建了整个弦图,不包括刻度和标签。我们将省略这部分内容,但请随意查看带有文本的示例代码,以了解这是如何实现的。

展示信息流动的技术

最后两个布局和相应的可视化有助于观众了解数据如何随时间流动或通过中间点而变化。

使用流图显示值的变化

流图展示了多系列数据值的变化,就像流动的数据流一样。每个流的高度代表该流在某一时刻的值。

它们对于演示某些类别在不同点沿图开始或结束的位置非常有用。常见的例子包括票房收入或流媒体上各种艺术家听众数量的数据,这些数据随时间变化。

为了演示流图,我们将使用在 goo.gl/HTL4HG 可用的数据。

这些数据由四个系列的数据组成:

 [
  [ 20, 49, 67, 16,  0, 19, 19, 0,  0, 1, 10,  5, 6,  1,  1 ],
  [ 4,   6,  3, 34,  0, 16,  1, 2,  1, 1,  6,  0, 1, 56,  0 ],
  [ 2,   8, 13, 15,  0, 12, 23, 15,10, 1,  0,  1, 0,  0,  6 ],
  [ 3,   9, 28,  0, 91,  6,  1, 0,  0, 0,  7, 18, 0,  9, 16 ]
]

在以下位置可以找到在线示例:

注意

bl.ock (10.8): goo.gl/LMd3F3

下面的结果是流图:

使用流图显示值的变化

此图使我们能够看到每个数据系列在每次测量点如何相互关联。在某种程度上,它就像堆叠区域图,但与每个都固定在共同的基线不同,图表的底部位置也可以变化。

示例首先加载数据并设置主要的 SVG 元素:

var url = 'https://gist.githubusercontent.com/d3byex/25129228aa50c30ef01f/raw/4393a0e579cbfd9bb20a431ce93c72fb1ea23537/streamgraph.json';
d3.json(url, function (error, rawData) {
    var width = 960, height = 500;
    var svg = d3.select('body')
        .append('svg')
        .attr({
            'width': width,
            'height': height
        });

我们需要对数据进行一些调整,因为布局函数的调用将期望它以与区域图相同的格式,即具有 xy 属性的对象的数组数组。以下代码创建了这个,使用数组中值的每个位置作为 x 值:

var data = Array();
d3.map(rawData, function (d, i) {
    data[i] = d.map(function (i, j) {
        return { x: j, y: i };
    });
});

接下来,代码创建轴,其中 X 轴是一个线性轴,表示每个系列中的点数:

var numPointsPerLayer = data[0].length;

var xScale = d3.scale.linear()
    .domain([0, numPointsPerLayer - 1])
    .range([0, width]);

布局是熟悉的堆叠布局,就像在区域图示例中使用的那样,但我们通过调用 .offset('wiggle') 来链式调用:

var layers = d3.layout.stack()
    .offset('wiggle')(data);

代码的其余部分继续像区域图一样,使用区域路径生成器和类似缩放的 Y 轴。

通过多个节点表示流程

与显示连续流动的流图不同,桑基图强调流动量的比例变化。这有点像弦图,但桑基图能够可视化比仅两个项目之间的更复杂的流动。

在 Sankey 图中,节点之间的线条宽度表示两个节点之间的流量量。通常,流量从左侧的一个或多个节点开始,通过中间节点,然后终止在右侧的节点上。

示例图表使用可在 goo.gl/lgQZBz 找到的数据。这些数据包括八个节点的声明以及节点之间的链接和节点之间的流量:

{
"nodes":[
  {"node":0, "name":"Source 1"},
  {"node":1, "name":"Source 2"},
  {"node":2, "name":"First Level Distribution"},
  {"node":3, "name":"Second Level Distribution 1"},
  {"node":4, "name":"Terminus 1"},
  {"node":5, "name":"Terminus 2"},
  {"node":6, "name":"Second Level Distribution 2"},
  {"node":7, "name":"Source 3"}
],
"links":[
  {"source":0, "target":2, "value":6},
  {"source":0, "target":4, "value":2},

  {"source":1, "target":2, "value":4},
  {"source":1, "target":3, "value":2},
  {"source":1, "target":6, "value":1},

  {"source":2, "target":3, "value":5},
  {"source":2, "target":4, "value":3},
  {"source":2, "target":6, "value":2},

  {"source":3, "target":4, "value":4},
  {"source":3, "target":5, "value":4},

  {"source":6, "target":5, "value":5},
  {"source":7, "target":6, "value":2},
  {"source":7, "target":3, "value":1}
] 
}

在以下位置可以找到在线示例:

注意

bl.ock (10.9): goo.gl/exZkI4

从这些数据生成的 Sankey 图如下所示:

通过多个节点表示流

Sankey 布局被视为 D3.js 的一个插件。它不在基础库中,因此您需要检索代码,并确保在您的应用程序中引用它。此代码可在 github.com/d3/d3-plugins/tree/master/sankey 找到,或者您可以从本书的示例中获取它。

示例首先通过加载数据并创建主要的 SVG 元素开始:

var url = 'https://gist.githubusercontent.com/d3byex/25129228aa50c30ef01f/raw/e6ea7c4728e45fb8d0464b21686eec806687e117/sankey.json';
d3.json(url, function(error, graph) {
    var width = 950, height = 500;
    var svg = d3.select('body')
        .append('svg')
        .attr({
            width: width,
            height: height
        });
    var mainGroup = svg.append('g');

我们使用以下方式创建布局。这里有很多参数可以指定节点的大小、填充、整个图表的大小、在您的数据中获取链接和节点的地方,以及布局指定用于定位节点的迭代次数:

var sankey = d3.sankey()
    .nodeWidth(36)
    .nodePadding(40)
    .size([width, height])
    .nodes(graph.nodes)
    .links(graph.links)
    .layout(10);

流路径(链接)通过创建表示流的路径来渲染。路径的结构是通过引用 sankey.link() 提供的,这是一个创建流路径数据的函数:

mainGroup.append('g')
    .selectAll('g.link')
    .data(data.links)
    .enter()
    .append('path')
    .attr({
        d: sankey.link(),
        fill: 'none',
        stroke: '#000',
        'stroke-opacity': 0.2,
        'stroke-width': function(d) { return Math.max(1, d.dy) }
    })
    .sort(function(a, b) { return b.dy - a.dy; });

现在我们创建一个组来存放节点,并根据布局提供的 xy 属性将它们放置到合适的位置。.node 样式仅用于区分这些组的选择与路径(使用 .link)的选择:

var nodes = mainGroup.append('g')
    .selectAll('g.node')
    .data(data.nodes)
    .enter()
    .append('g')
    .attr('transform', function(d) {
        return 'translate(' + d.x + ',' + d.y + ')';
    });

然后,我们在组中插入一个彩色矩形:

var color = d3.scale.category20();
nodes.append('rect')
    .attr({
        height: function(d) { return d.dy; },
        width: sankey.nodeWidth(),
        fill: function(d, i) {
            return d.color = color(i);
        },
        stroke: 'black'
    });

我们还包含文本来描述节点,并使用一些逻辑来定位标签:

nodes.append('text')
    .attr({
        x: -6,
        y: function(d) { return d.dy / 2; },
        dy: '.35em',
        'text-anchor': 'end'
    })
    .style('font', '10px sans-serif')
    .text(function(d) { return d.name; })
    .filter(function(d) { return d.x < width / 2; })
    .attr({
        x: 6 + sankey.nodeWidth(),
        'text-anchor': 'start'
    });

摘要

我们在本章中涵盖了大量的内容。总体重点是创建利用 D3.js 布局对象的复杂图表。这些图表包括不同类别的大量图表,包括堆叠、打包、聚类、基于流、层次和径向。

D3.js 的一个优点是它允许您轻松地创建这些复杂视觉效果的便捷性。它们是模式导向的,因此每个的代码通常非常相似,只是布局对象略有不同。

在下一章中,我们将详细探讨一种特定的图形:网络图。这些图扩展了我们本章中看到的一些概念,如流和层次结构,使我们能够可视化非常复杂的数据,如社交网络中的数据。

第十一章. 信息网络可视化

在本章中,我们将探讨一种称为力导向图的具体布局类型。这类可视化通常用于显示网络信息:相互连接的节点。

一种特别常见的网络可视化类型是社交网络中的关系。社交网络的可视化可以帮助你理解不同的人是如何形成各种关系的。这包括其他人之间的链接,以及人们如何形成群体或朋友圈,以及这些群体如何相互关联。

D3.js 提供了使用力导向网络创建非常复杂网络可视化的广泛功能。我们将概述这些图的几个代表性示例,简要介绍它们的工作理论,并深入几个示例以展示它们的创建和使用。

具体来说,在本章中我们将涵盖以下内容:

  • 力导向图的简要概述

  • 创建基本的力导向图

  • 修改链接的长度

  • 强制节点相互远离

  • 标记节点

  • 强制节点保持在原地

  • 使用链接视觉表达方向性和类型

力导向图的概述

网络数据有多种呈现方式。其中一种特别常见的方式,我们将在本章中探讨,就是使用一类称为力导向布局的算法。

这些算法在二维或三维空间中定位图中的节点。定位是通过沿边和节点分配力来完成的,然后使用这些力来模拟将节点移动到整个系统中能量最小化的位置。

以下是从维基百科中选取的力导向图的代表性图片。节点是页面,节点之间的线条代表页面之间的链接。节点的大小根据特定节点的链接数量而变化:

力导向图的概述

力导向图的基本组成部分是图中的节点以及这些节点之间的关系。图是迭代布局的,通常在过程中进行动画处理,并且可能需要相当多的迭代才能稳定

D3.js 中的力布局算法考虑了许多因素。算法的一些重要参数以及它们如何影响模拟如下:

  • 大小(宽度和高度):这代表图表的整体大小,以及重心,通常是图表的中心。图中的节点将倾向于移动到这个点。如果节点没有初始的xy位置,那么它们将在x方向的 0 到宽度之间和y方向的 0 到高度之间随机放置。

  • 电荷:这描述了一个节点吸引其他节点的程度。负值会推开其他节点,而正值会吸引。任一方向的值越大,该方向的力就越强。

  • 电荷距离:这指定了电荷有效作用的最大距离(默认为无穷大)。较小的值有助于布局的性能,并导致节点在簇中的布局更加局部化。

  • 摩擦力:表示速度延迟的量。此值应在 [0, 1] 范围内。在布局的每个时间点,每个节点的速度都会乘以这个值。因此,使用 0 的值将使所有节点保持在原地,而 1 则表示无摩擦的环境。介于两者之间的值最终会减慢节点的速度,使整体运动足够小,当总移动量低于布局阈值时,模拟可以被认为是完成的,此时图被称为稳定。

  • 链接距离:这指定了模拟结束时节点之间的期望距离。在模拟的每个时间点,链接节点之间的距离与这个值进行比较,节点会向彼此移动或远离彼此,以尝试达到期望的距离。

  • 链接强度:这是一个 [0, 1] 范围内的值,指定了在模拟过程中链接距离的可拉伸性。0 的值是刚性的,而 1 是完全灵活的。

  • 重力:这指定了每个节点对布局中心的吸引力。这是一个弱几何约束。也就是说,整体重力越高,它就越远离渲染中心。此值对于保持布局在图中的相对居中以及防止断开连接的节点飞向无穷远是有用的。

我们将介绍足够的这些参数,以便能够制作有用的可视化。

注意

所有布局参数的更多详细信息可在 github.com/mbostock/d3/wiki/Force-Layout 找到。

除了有助于节点实际布局的参数外,还可以使用其他视觉元素在力导向图中传达底层信息中的各种值:

  • 节点的颜色可以用来区分特定类型的节点,例如人们与雇主之间的区别,或者根据它们的关系,例如所有在特定雇主工作的个人,或者节点与另一个节点之间的分离度数。

  • 节点的大小,通常表示节点的重要性程度。通常,链接的数量会影响节点的大小。

  • 链接的渲染厚度可以用来表明某些链接比其他链接有更大的影响力,或者链接是特定类型的,即公路与铁路。

  • 链接的方向性,表明链接要么没有方向性,要么是一向或双向的。

一个简单的力导向图

我们的第一个示例将演示如何构建一个力导向图。在线示例可在以下链接找到:

注意

bl.ock (11.1): goo.gl/ZyxCej

我们所有的力导向图都将从加载表示网络的表示数据开始。此示例使用gist.githubusercontent.com/d3byex/5a8267f90a0d215fcb3e/raw/ba3b2e3065ca8eafb375f01155dc99c569fae66b/uni_network.json中的数据。

以下是在前一个链接中文件的包含内容:

{
  "nodes": [
    { "name": "Mike" },
    { "name": "Marcia" },
    { "name": "Chrissy" },
    { "name": "Selena" },
    { "name": "William" },
    { "name": "Mikael" },
    { "name": "Bleu" },
    { "name": "Tagg" },
    { "name": "Bob" },
    { "name": "Mona" }
  ],
  "edges": [
    { "source": 0, "target":  1 },
    { "source": 0, "target":  4 },
    { "source": 0, "target":  5 },
    { "source": 0, "target":  6 },
    { "source": 0, "target":  7 },
    { "source": 1, "target":  2 },
    { "source": 1, "target":  3 },
    { "source": 1, "target":  5 },
    { "source": 1, "target":  8 },
    { "source": 1, "target":  9 },
  ]
}

D3.js 中的力导向布局算法需要数据以这种格式。这需要是一个具有nodesedges属性的对象。nodes属性可以是任何其他你喜欢的对象的数组。这些通常是你的数据项。

edges数组必须由同时具有sourcetarget属性的对象组成,每个对象的值是对nodes数组中源节点和目标节点的索引。你可以添加其他属性,但我们至少需要提供这两个。

要开始渲染图形,我们加载这些数据并创建主要的 SVG 元素:

var url = 'https://gist.githubusercontent.com/d3byex/5a8267f90a0d215fcb3e/raw/ba3b2e3065ca8eafb375f01155dc99c569fae66b/uni_network.json';
d3.json(url, function(error, data) {
    var width = 960, height = 500;
    var svg = d3.select('body').append('svg')
        .attr({
            width: width,
            height: height
        });

下一步是使用d3.layout.force()创建图的布局。有许多选项,其中一些我们将在示例中探讨,但我们从以下选项开始:

var force = d3.layout.force()
    .nodes(data.nodes)
    .links(data.edges)
    .size([width, height])
    .start();

这通过.node().link()函数分别通知布局关于节点和链接的位置。调用.size()通知布局要约束布局的面积,并对图有两个影响:重力中心和初始随机位置。

调用.start()开始模拟,必须在创建布局并分配节点和链接之后调用。如果节点和链接之后发生变化,可以再次调用它来重新启动模拟。请注意,模拟是在此函数返回后开始,而不是立即开始。因此,你仍然可以对视觉进行其他更改。

现在我们可以渲染链接和节点:

var edges = svg.selectAll('line')
    .data(data.edges)
    .enter()
    .append('line')
    .style('stroke', '#ccc')
    .style('stroke-width', 1);

var colors = d3.scale.category20();
var nodes = svg
    .selectAll('circle')
    .data(data.nodes)
    .enter()
    .append('circle')
    .attr('r', 10)
    .attr('fill', function(d, i) {
        return colors(i);
    })
    .call(force.drag);

注意,我们还链式调用了.call()函数,传递给它我们布局的force.drag函数的引用。此函数由布局对象提供,以便我们能够轻松地拖动网络中的节点。

需要执行的一个额外步骤是。力导向布局是一种模拟,由一系列必须处理的tick组成。每个 tick 代表布局算法已经遍历了节点并重新计算了它们的位置,这为我们提供了重新定位可视元素的机会。

要连接到 tick,我们可以使用force.on()函数,告诉它我们想要监听tick事件,并在每个事件上调用一个函数,以便我们能够重新定位我们的视觉元素。以下是我们为此活动编写的函数:

force.on('tick', function() {
    edges.attr({
        x1: function(d) { return d.source.x; },
        y1: function(d) { return d.source.y; },
        x2: function(d) { return d.target.x; },
        y2: function(d) { return d.target.y; }
    });

    nodes.attr('cx', function(d) { return d.x; })
         .attr('cy', function(d) { return d.y; });
});

在每个时钟周期,我们需要适当地重新定位每个节点和边。注意我们是如何做到这一点的。D3.js 为我们的数据添加了 xy 属性,这些是计算出的位置。它还为每个数据节点添加了 pxpy 属性,代表之前的 xy 位置。

注意

您还可以使用 startend 作为 on() 方法的参数,以捕获模拟开始和完成时的状态。

运行此程序后,输出将类似于以下内容:

一个简单的力导向图

每次执行此示例时,节点将位于不同的位置。这是由于算法为每个节点指定了一个随机的起始位置。

在这个例子中,节点非常接近,以至于链接几乎看不见。但您可以使用鼠标拖动节点,这将显示链接。同时请注意,布局在您拖动时执行,当拖动的节点被释放时,节点会跳回到中间。

使用链接距离来分散节点

在前面的例子中,这些节点太靠近了,我们很难看到边缘。为了在节点之间增加更多距离,我们可以指定链接距离。以下示例演示了这一点:

注意

bl.ock (11.2): goo.gl/dd1T3O

此示例对上一个示例所做的唯一修改是增加链接距离到 200(默认为 20):

var force = d3.layout.force()
    .nodes(data.nodes)
    .links(data.edges)
    .size([width, height])
    .linkDistance(200)
    .start();

这种修改导致模拟结束时节点间距更好:

使用链接距离来分散节点

拖动节点。这将演示一些在游戏中起作用的物理现象:

  • 无论您移动哪个节点,图都会回到可视化的中心。这是重力对布局的影响,以及它被放置在中心的效果。

  • 节点总是聚集在一起,但总是至少保持链接距离。重力将它们吸引到中心,默认电荷 -30 使节点相互推开,但不足以拉伸链接或使节点逃离重力中心。

  • 前面的观点在可视化结果中有一个重要的推论。节点之间的链接通常会交叉。在许多网络可视化中,人们希望尝试使链接不交叉,因为这简化了跟踪链接的能力,从而简化了关系。我们将在下一个示例中探讨如何解决这个问题。

为节点添加排斥力以防止交叉链接

我们尝试防止交叉链接的方法是为每个节点应用一定量的排斥力。当排斥力超过重心的拉力时,节点可以远离这一点。它们也会远离其他节点,倾向于将结果图扩展到最大尺寸,从而造成链接不交叉的效果。

以下示例演示了节点排斥:

注意

bl.ock (11.3): goo.gl/PCHK68

此示例对之前的示例进行了两项修改:

var force = d3.layout.force()
    .nodes(data.nodes)
    .links(data.edges)
    .size([width, height])
    .linkDistance(1)
    .charge(-5000)
    .start();

这创建了一个值为-5000的电荷,这意味着节点实际上相互排斥。还有一个较小的链接距离,因为排斥力会将节点推开很多,因此拉伸了链接。如果将链接保持在200,会使链接非常长。

当这个模拟完成时,你将得到一个如下所示的图形:

为防止交叉链接向节点添加排斥力

注意节点现在如何尽可能地彼此远离!链接也被拉伸了很多,尽管链接距离设置为1。链接默认是弹性的,会根据系统中的电荷和重力拉伸或压缩。

重复运行此模拟。你会注意到它几乎总是收敛到这个相同的形状,节点在图中的相对位置相同(组本身可能每次都会旋转不同的角度)。在非常罕见的情况下,可能仍然存在交叉边,但排斥力设置得足够高,以防止这种情况在大多数执行中出现。

节点标注

我们在力导向图中缺少的是节点标注,这样我们就可以知道节点代表什么数据。以下示例演示了如何向节点添加标签:

注意

bl.ock (11.4): goo.gl/31VfSU

在此先前的示例中,与之前不同之处在于,我们不是用一个单独的圆形 SVG 元素来表示一个节点,而是用一个包含圆形和文本元素的组来表示:

var nodes = svg.selectAll('g')
    .data(data.nodes)
    .enter()
    .append('g')
    .call(force.drag);

var colors = d3.scale.category20();
nodes.append('circle')
    .attr('r', 10)
    .attr('fill', function (d, i) {
        return colors(i);
    })
    .call(force.drag);

nodes.append('text')
    .attr({
            dx: 12,
            dy: '.35em',
            'pointer-events': 'none'
        })
.style('font', '10px sans-serif')
.text(function (d) { return d.name });

然后在处理 tick 事件的过程中,我们需要进行一个额外的更改。由于我们现在需要定位 SVG 组而不是圆形,因此此代码需要将组转换到位置而不是使用xy属性:

force.on('tick', function () {
    edges.each(function (d) {
        d3.select(this).attr({
            x1: d.source.x,
            y1: d.source.y,
            x2: d.target.x,
            y2: d.target.y
        });
    });

    nodes.attr('transform', function (d) { 
    return 'translate(' + d.x + ',' + d.y + ')'; 
    });
});

此示例的结果现在看起来如下:

节点标注

使节点固定在位置

在检查力网络中的节点时,一个常见且令人沮丧的问题是,当你移动一簇其他节点中的一个节点以更好地查看它,然后释放它时,它会回到原来的位置。我敢打赌,你已经在使用这些示例时经历过这种疯狂。

这可以通过使用一个称为使节点粘性的概念来解决。以下示例演示了这一操作:

注意

bl.ock (11.5): goo.gl/nmQu3d

现在,当你拖动一个节点时,它将停留在你放置的位置。固定位置的节点将变为具有粗黑边框。要释放一个节点,双击它,它将被放回力布局中。

以下图像显示了三个固定位置的节点:

使节点固定在位置

现在我们来检查需要进行的修改。这是通过向我们的代码中添加几个函数链来创建圆圈来实现的:

nodes.append('circle')
    .attr('r', 10)
    .attr({
        r: 10,
        fill: function(d, i) {
            return colors(i);
        },
        stroke: 'black',
        'stroke-width': 0
    })
    .call(force.drag()
        .on("dragstart", function(d) {
            d.fixed = true;
            d3.select(this).attr('stroke-width', 3);
        }))
    .on('dblclick', function(d) {
        d.fixed = false;
        d3.select(this).attr('stroke-width', 0);
    });

当圆圈首次创建时,除了指定填充颜色外,它还将有一个黑色但宽度为 0 的描边颜色。

然后,我们不再使用 .call(force.drag),而是用自定义的拖动实现来替换它。在拖动开始时,代码将数据对象上的属性 fixed 设置为 true。如果力导向布局对象看到该对象具有此属性,并且其值为 true,则它将不会尝试重新定位项目。然后,边框被设置为宽度为三像素。

最后的修改是处理 dblclick 鼠标事件,该事件将固定属性设置为 false,使节点成为布局的一部分,然后隐藏粗边框。

为链接添加方向性标记和样式

节点之间的关系可以是单向的或双向的。我们之前编写的代码假设是单向的,或者可能是非定向的。现在让我们看看我们如何通过在线条上添加箭头来表达关系的方向。

我们将要创建的例子将假设数据中边集合的每个条目代表从源到目标的一个单向链接。如果存在双向链接,则 edges 中将有一个额外的条目,其源和目标被反转。

这个例子将使用来自 gist.githubusercontent.com/d3byex/5a8267f90a0d215fcb3e/raw/8469d2a7da14c1c8180ebb2ea8ddf1e2944f990c/multi_network.html 的数据,该数据添加了几个双向链接以及一个 type 属性来指定关系的类型。

在这个数据中的边集合如下。节点没有变化:

"edges": [
  { "source": 0, "target":  1, "type": "spouse" },
  { "source": 1, "target":  0, "type": "spouse" },
  { "source": 0, "target":  4, "type": "coworker"},
  { "source": 4, "target":  0, "type": "coworker"},
  { "source": 0, "target":  5, "type": "father" },
  { "source": 5, "target":  0, "type": "son" },
  { "source": 0, "target":  6, "type": "master" },
  { "source": 6, "target":  0, "type": "pet" },
  { "source": 0, "target":  7, "type": "master" },
  { "source": 1, "target":  2, "type": "spouse" },
  { "source": 1, "target":  3, "type": "friend" },
  { "source": 1, "target":  5, "type": "mother" },
  { "source": 1, "target":  8, "type": "pet" },
  { "source": 8, "target":  1, "type": "master" },
  { "source": 1, "target":  9, "type": "pet" },
  { "source": 5, "target": 10, "type": "pet" }
]

注意

bl.ock (11.6): goo.gl/hucTe1

下面的图像展示了这个例子的结果:

为链接添加方向性标记和样式

让我们看看代码是如何创建这个可视化的。

在这个例子中,首先改变的是它使用样式来为不同类型的链接着色:

.link {
    fill: none;
    stroke: #666;
    stroke-width: 1.5px;
}

.link.spouse {
    stroke: green;
}

.link.son {
    stroke: blue;
}

.link.father {
    stroke: blue;
    stroke-dasharray: 0, 2, 1;
}

.link.friend {
    stroke: teal;
}

.link.pet {
    stroke: purple;
}

.link.master {
    stroke: purple;
    stroke-dasharray: 0, 2, 1;
}

.link.ruler {
    stroke: red;
    stroke-dasharray: 0, 2, 1;
}

.link.coworker {
    stroke: green;
    stroke-dasharray: 0, 2, 1;
}

加载数据和设置 SVG 元素以及力导向布局的代码与上一个例子相同。另一个区别是代码需要确定特定的链接类型,因为它们将被用于标记和样式:

var linkTypes = d3.set(data.edges.map(function (d) {
    return d.type;
})).values();

接下来,为每个链接类型创建了标记。这些将通过设置 d 属性的最后一个链式函数来渲染带有箭头的曲线路径:

svg.append("defs")
    .selectAll("marker")
    .data(linkTypes)
    .enter()
    .append("marker")
    .attr({
        id: function (d) { return d; },
        viewBox: "0 -5 10 10",
        refX: 15,
        refY: -1.5,
        markerWidth: 6,
        markerHeight: 6,
        orient: "auto"
    })
    .append("path")
    .attr("d", "M0,-5L10,0L0,5"); 

下一步是创建边:

var edges = svg.append("g")
    .selectAll("path")
    .data(force.links())
    .enter()
    .append("path")
    .attr("class", function (d) {
        return "link " + d.type;
    })
    .attr("marker-end", function(d) {
         return "url(#" + d.type + ")";
    }); 

代码现在使用路径而不是线条。路径的 d 属性在此时尚未指定。它将在模拟的每个刻度时设置。此路径通过将类型作为类名的一部分来引用一种样式,而 marker-end 属性指定了用于此段落的标记定义。

圆圈和文本的创建方式与上一个示例相同,最后的变化是将刻度处理程序修改为不仅重新定位节点,而且根据弧线重新生成路径:

force.on("tick", function () {
    edges.attr("d", function (d) {
        var dx = d.target.x - d.source.x,
            dy = d.target.y - d.source.y,
            dr = Math.sqrt(dx * dx + dy * dy);
        return "M" + d.source.x + "," + d.source.y + "A" +
                dr + "," + dr + " 0 0,1 " +
                d.target.x + "," + d.target.y;
    });
    nodes.attr("transform", function (d) {
        return "translate(" + d.x + "," + d.y + ")";
    });
}); 

摘要

在本章中,我们解释了如何使用 D3.js 生成力导向图。这类图是一些最有趣的图之一,可以用来可视化大量相互连接的数据,例如社交网络。

本章从介绍创建图的基本概念开始,通过一个逐步改进的示例来展示如何几个参数影响图表的结果。

我们随后介绍了几种增强和使图表更易于使用的技巧。这些包括用文本标记节点、用图像替换节点以及为显示方向和类型而着色链接。

在下一章中,我们将介绍如何使用 D3.js 创建地图。我们还将学习很多关于 GeoJSON 和 TopoJSON 的知识,这两者结合 D3.js 可以让我们基于地理数据创建复杂的视觉图表。

第十二章. 使用 GeoJSON 和 TopoJSON 创建地图

D3.js 提供了广泛的创建地图的能力,并帮助您将数据作为地图的一部分或作为叠加层来展示。D3.js 中的映射函数利用一种称为 GeoJSON 的数据格式,这是一种编码地理信息的 JSON 格式。

D3.js 中地图的另一种常见数据类型是 TopoJSON。TopoJSON 是 GeoJSON 的更压缩形式。这两种格式都用于表示创建地图所需的制图信息,D3.js 处理这些数据并执行其将信息转换为可视化地图的 SVG 路径的常规魔法。

本章将从 GeoJSON 和 TopoJSON 的简要概述开始。这将为您理解如何使用 D3.js 表示和渲染地图奠定基础。然后,我们将通过使用这两种数据格式渲染各种类型的地图、根据数据对地图内的几何形状进行着色以及在这些地图的特定位置叠加信息,进入许多示例。

本章我们将涵盖的具体主题包括:

  • TopoJSON 和 GeoJSON 的简要概述

  • 使用 GeoJSON 绘制美国地图

  • 使用 TopoJSON 绘制世界各国的地图

  • 为构成地图的几何形状设置样式

  • 地图的平移和缩放

  • 与地球仪的交互

  • mouseover 事件中突出显示几何形状的边界

  • 在地图的特定位置添加符号

  • 根据数据(使用渐变)渲染区域地图

介绍 TopoJSON 和 GeoJSON

几乎所有的 D3.js 地图示例都将使用 GeoJSONTopoJSON。GeoJSON 是一种开放、标准的基于 JSON 的格式,用于表示基本地理特征以及这些特征的非空间属性(如城市或地标的名字)。

GeoJSON 的核心几何形状是点、线字符串和多边形。GeoJSON 实体的基本描述使用以下语法:

{ 
    "type": name of the type of geometry (point, line string, ...)
    "coordinates": one or more tuple of latitude / longitude
}

让我们看看 GeoJSON 中可用的四种基本几何类型。一个 表示二维空间中的一个位置,由一对纬度和经度组成。点通常用于指定地图上某个要素的位置(例如建筑物):

示例 代表性 GeoJSON
介绍 TopoJSON 和 GeoJSON
{
    "type": "Point", 
    "coordinates": [30, 10]
}

|

LineString 描述了一系列点,这些点之间用线连接,从第一个点开始,经过所有中间点,最后到达最后一个坐标。这个名字让人联想到在所有点之间拉紧一根线的景象。这些形状通常用于表示诸如道路或河流等物品:

示例 代表性 GeoJSON
介绍 TopoJSON 和 GeoJSON
{ 
    "type": "LineString", 
    "coordinates": [
        [30, 10], [10, 30], 
        [40, 40] ]
}

|

多边形 是一个闭合形状,通常由三个或更多点组成,其中最后一个点与第一个点相同,形成一个闭合形状。其 JSON 表示如下;请注意,坐标是一个元组的数组数组:

示例 代表性 GeoJSON
介绍 TopoJSON 和 GeoJSON
{ 
    "type": "Polygon", 
    "coordinates": 
    [
        [[30, 10], [40, 40],
         [20, 40], [10, 20], 
         [30, 10]] 
    ]
}

|

数组数组的目的是允许定义多个多边形,这些多边形相互排斥,从而允许在多边形区域内排除一个或多个多边形区域:

示例 代表性 GeoJSON
介绍 TopoJSON 和 GeoJSON
{ 
    "type": "Polygon", 
    "coordinates": 
        [
          [[35, 10], [45, 45], 
           [15, 40], [10, 20], 
           [35, 10]], 
          [[20, 30], [35, 35],
           [30, 20], [20, 30]] 
        ]
}

|

可以定义多部分几何形状,其中特定的几何类型被重用,并且坐标描述了该几何类型的多个实例。这些类型是前面带有 Multi 的类型——MultiPointMultiLineStringMultiPolygon。每个类型如下所示:

类型 示例 代表性 GeoJSON
多点 介绍 TopoJSON 和 GeoJSON
{ 
    "type": "MultiPoint", 
    "coordinates": 
     [[10, 40], [40, 30], 
      [20, 20], [30, 10]]
}

|

多线字符串 介绍 TopoJSON 和 GeoJSON
{ 
"type": MultiLineString", 
   "coordinates": 
   [
     [[10, 10], [20, 20],
      [10, 40]], 
    [[40, 40], [30, 30], 
     [40, 20], [30, 10]] 
   ]
}

|

多边形 介绍 TopoJSON 和 GeoJSON
{ 
    "type": "MultiPolygon", 
    "coordinates": [
        [ [[40, 40], [20, 45], [45, 30], [40, 40]] ], 
        [ [[20, 35], [10, 30], [10, 10], [30, 5], [45, 20],
           [20, 35]], 
          [[30, 20], [20, 15], [20, 25], [30, 20]]  ]  ]
}

|

这些基本几何形状可以封装在一个 特性 中。特性包含一个几何形状和一组属性。例如,以下定义了一个包含点几何形状的特性,并且具有单个属性 name,可以用来描述该特性的名称:

{
  "type": "Feature",
  "geometry": {
    "type": "Point",
    "coordinates": [46.862633, -114.011593]   
  },
  "properties": {
    "name": "Missoula"  
   }
}

我们可以在层次结构中再上升一级,并定义称为 特性集合 的概念:

{ 
    "type": "FeatureCollection",
    "features": [
      { "type": "Feature",
        "geometry": {"type": "Point", 
        "coordinates": [102.0, 0.5]},
        "properties": {"prop0": "value0"} },
      { "type": "Feature",
        "geometry": {
          "type": "LineString",
          "coordinates": [
            [102.0, 0.0], [103.0, 1.0],[104.0, 0.0], [105.0, 1.0]]
          },
        "properties": { "prop0": "value0", "prop1": 0.0 }
        },
      { "type": "Feature",
         "geometry": {
           "type": "Polygon",
           "coordinates": [
             [ [100.0, 0.0], [101.0, 0.0], [101.0, 1.0],
               [100.0, 1.0], [100.0, 0.0] ]  ]
         },
         "properties": {
           "prop0": "value0", "prop1": {"this": "that"} }
         }
       ]
     }

通过组合几何形状、特性和特性集合,可以描述非常复杂的形状,如地图。

但 GeoJSON 的问题之一是它非常冗长,并且特定的几何形状和特性不能被重用。如果需要在多个位置使用相同的几何形状,则必须完全重新指定第二次。

为了帮助解决这个问题,TopoJSON 被创建出来。TopoJSON 为拓扑编码和重用提供了额外的结构。而不是离散地描述每个几何形状,TopoJSON 允许你定义几何形状,然后使用称为 的概念将它们拼接在一起。

Arcs 允许 TopoJSON 消除冗余,并提供比 GeoJSON 更紧凑的表示形式。据称,TopoJSON 通常可以提供比 GeoJSON 高达 80% 的压缩率。考虑到网页下载的每一毫秒都很重要,这对于使用大量几何数据集的用户体验来说可能非常关键。

TopoJSON 的完整解释超出了本书的范围,但为了简要展示它,我们可以查看以下内容并简要检查其内容:

{
  "type": "Topology",
  "objects": {
    "example": {
      "type": "GeometryCollection",
      "geometries": [
        { "type": "Point",
          "properties": {
            "prop0": "value0" },
          "coordinates": [102, 0.5]
        },
        { "type": "LineString",
          "properties": {
            "prop0": "value0",
            "prop1": 0 },
          "arcs": [0]
        },
        { "type": "Polygon",
          "properties": {
            "prop0": "value0",
            "prop1": {
              "this": "that"
            }
          },
          "arcs": [[-2]]
        }
      ]
    }
  },
  "arcs": [
    [[102, 0], [103, 1], [104, 0], [105, 1]],
    [[100, 0], [101, 0], [101, 1], [100, 1], [100, 0]]  ]
}

此 TopoJSON 对象有三个属性:typeobjectsarcstype 的值始终为 "topology"objects 属性由一个类似于 GeoJSON 中的几何形状集合组成,不同之处在于,对象可以指定一个或多个弧,而不是坐标。

弧是 TopoJSON 与 GeoJSON 之间的主要区别,代表了重用的手段。弧属性提供了一个位置数组的数组,其中位置本质上是一个坐标。

这些弧是通过基于 0 的数组语义的几何形状引用的。因此,前述代码中的 LineString 几何形状通过指定 arcs[0] 来引用拓扑对象中的第一个弧。

多边形对象引用了一个值为 -2 的弧。负弧值指定了应该利用的弧的补码。这本质上意味着弧中的位置应该被反转。因此,-2 指示获取第二个弧的反转位置。这是 TopoJSON 用来重用和压缩数据的一种策略。

还有其他选项,例如变换和边界框,以及其他规则。对于更详细的规范,请参阅github.com/mbostock/topojson-specification

注意

关于 TopoJSON 的重要事项是,D3.js 本身仅使用 GeoJSON 数据。要使用 TopoJSON 格式的数据,您需要使用可在github.com/mbostock/topojson找到的 TopoJSON 插件。此插件将 TopoJSON 转换为 D3.js 函数可以使用的 GeoJSON,从而为您的 D3.js 应用程序提供 TopoJSON 的功能。

创建美国地图

我们的第一批示例将探讨创建美国地图的过程。我们将从一个加载数据并渲染地图的示例开始,然后我们将检查如何对地图进行样式化以使其更易于观察,接着将展示如何修改投影以更有效地渲染内容。

使用 GeoJSON 创建我们第一张美国地图

我们的第一张地图将渲染美国地图。我们将使用一个 GeoJSON 数据文件,us-states.json,该文件可在gist.githubusercontent.com/d3byex/65a128a9a499f7f0b37d/raw/176771c2f08dbd3431009ae27bef9b2f2fb56e36/us-states.json找到。以下是该文件的几行内容,展示了州形状在文件中的组织方式:

{"type":"FeatureCollection","features":[
  { "type": "Feature",
    "id": "01",
    "properties": { "name": "Alabama" },
    "geometry": {
      "type": "Polygon",
      "coordinates": [ [
          [ -87.359296, 35.00118 ], [ -85.606675, 34.984749 ], 
          [ -85.431413, 34.124869 ], [ -85.184951, 32.859696 ], 
          [ -85.069935, 32.580372 ], [ -84.960397, 32.421541 ],
          [ -85.004212, 32.322956 ], [ -84.889196, 32.262709 ], 
...

顶级 FeatureCollection 包含一个特征数组,每个元素都是一个州(或地区)以及华盛顿特区。每个州都是一个特征,具有单个属性 Name,以及一个表示州轮廓的多边形几何形状,该轮廓以经纬度元组表示。

示例的代码可在以下链接找到:

注意

bl.ock (12.1): goo.gl/dzKsVd

打开 URL 后,您将看到以下地图:

使用 GeoJSON 创建我们第一个美国地图

生成此地图所需的数据和渲染代码非常简单(按设计)。它首先创建主 SVG 元素:

var width = 950, height = 500;
var svg = d3.select('body')
    .append('svg')
    .attr({
        width: width,
        height: height
    });

GeoJSON 只是 JSON,可以用 d3.json() 加载:

var url = 'https://gist.githubusercontent.com/d3byex/65a128a9a499f7f0b37d/raw/176771c2f08dbd3431009ae27bef9b2f2fb56e36/us-states.json';
d3.json(url, function (error, data) {
    var path = d3.geo.path();
    svg.selectAll('path')
        .data(data.features)
        .enter()
        .append('path')
        .attr('d', path);
});
d3.json("/data/us-states.json", function (error, data) {

一旦我们有了数据,我们就可以创建一个 d3.geo.path()。此对象具有将 GeoJSON 中的要素转换为 SVG 路径的智能。然后代码将路径添加到主 SVG 元素中,绑定数据,并将路径的 d 属性设置为我们的 d3.geo.path() 对象。

哇,仅仅用几行代码,我们就绘制了一张美国地图!

美国地图的样式化

总体来说,这张图片比较暗,各州之间的边界并不特别明显。我们可以通过提供用于渲染地图的填充和描边值样式来改变这一点。

此示例的代码位于以下链接:

注意

bl.ock (12.2): goo.gl/chhKjz

当打开此 URL 时,您将看到以下地图:

美国地图的样式化

与前一个示例的唯一不同之处在于将填充设置为透明,并将边界设置为黑色:

svg.selectAll('path')
   .data(data.features)
   .enter()
   .append('path')
   .attr('d', path)
      .style({ fill: 'none', stroke: 'black' });

使用 albersUsa 投影

您可能对前两个示例中的地图有一些疑问。首先,地图是如何缩放到 SVG 元素的大小?其次,我能改变这个比例吗?为什么阿拉斯加和夏威夷被画在墨西哥通常所在的位置?

这些与一些关于投影的基本假设有关。投影是将地理数据(二维数据,纬度和经度),但实际上是在一个三维球体(地球)上,渲染到具有特定尺寸的二维表面上(您的计算机屏幕或浏览器视口)的一种方式。

在此示例中,D3.js 对这些因素做了一些隐含的假设。为了帮助说明这些假设,假设我们将 SVG 元素的大小更改为 500 x 250。运行此操作时,我们得到以下输出:

使用 albersUsa 投影

创建此代码的代码位于以下位置。与前一个示例相比,唯一的区别是 SVG 元素的宽度和高度都减半了:

注意

bl.ock (12.3): goo.gl/41wyCY

结果是实际渲染的大小相同,但由于容器较小,我们剪掉了地图的下半部和最右边的四分之一。

为什么会这样?这是因为,默认情况下,D3.js 使用一个称为albersUsa投影的投影,它附带一些假设:

  • 生成的地图尺寸为 1024 x 728

  • 地图位于宽度和高度的一半(512,364)

  • 投影还将阿拉斯加和夏威夷放在地图的左下角(啊哈!)

要改变这些假设,我们可以使用d3.geo.albersUsa()投影对象创建自己的albersUsa投影。此对象可用于指定结果的渲染的平移和缩放。

以下示例创建了一个albersUsa投影并定位了地图:

注意

bl.ock (12.4): goo.gl/1e4DGp

结果如下:

使用 albersUsa 投影

代码创建了一个d3.geo.albersUsa投影,并告诉它将美国地图的中心定位在[width/2, height/2]

var projection = d3.geo.albersUsa()
    .translate([width / 2, height / 2]);

然后将投影对象分配给d3.geo.path()对象,使用其.projection()函数:

var path = d3.geo.path()
    .projection(projection);

我们已经改变了地图的中心,但比例尺仍然是相同的尺寸。要改变比例尺,我们使用投影的.scale()函数。以下示例将比例尺设置为宽度,告诉 D3.js 地图的宽度不应该是 1024,而是widthheight的值:

注意

bl.ock (12.5): goo.gl/O51jPN

前面的示例生成了一个正确缩放的地图:

使用 albersUsa 投影

代码中唯一的不同之处在于对投影的.scale()的调用:

var projection = d3.geo.albersUsa()
    .translate([width / 2, height / 2])
    .scale([width]);

注意,我们只传递了一个比例尺值。投影沿着宽度缩放,然后自动按比例沿高度缩放。

创建世界的一个平坦地图

albersUsa投影是 D3.js 提供的许多投影对象之一。您可以在github.com/mbostock/d3/wiki/Geo-Projections中查看这些投影的完整列表。

我们没有足够的空间在这个书中展示所有这些,但其中一些值得努力展示几个 TopoJSON 概念。具体来说,我们将演示从 TopoJSON 源获取的世界各国地图的渲染,并将其投影到平坦和球面上。

在这些示例中,我们将使用 TopoJSON 数据库源代码提供的world-110m.json数据文件,该源代码可在gist.githubusercontent.com/d3byex/65a128a9a499f7f0b37d/raw/176771c2f08dbd3431009ae27bef9b2f2fb56e36/world-110m.json找到。

此数据表示具有特征的国界数据,以 110 米分辨率指定。

使用 TopoJSON 加载和渲染

现在我们来检查加载和渲染 TopoJSON。以下示例演示了该过程:

注意

bl.ock (12.6): goo.gl/aLhKKe

代码与前面的示例变化不大。变化发生在数据加载之后:

var path = d3.geo.path();
var countries = topojson.feature(world,
                           world.objects.countries).features;
svg.selectAll('path')
    .data(countries)
    .enter()
    .append('path')
    .attr('d', path)
    .style({
        fill: 'black',
        stroke: 'white'
    });

示例仍然使用d3.geo.path()对象,但此对象不能直接接受 TopoJSON。需要做的第一件事是提取代表国家的数据部分,这是通过调用topojson.feature()函数来完成的。

topojson变量在topojson.js文件中全局声明。它的.feature()函数,当给定一个 TopoJSON 对象(在这种情况下,world)和一个GeometryCollection(在这种情况下,world.objects.countries),返回一个 GeoJSON 特征,该特征可以被路径使用。

将地图渲染出来的选择绑定到这个结果上,从而得到以下地图:

使用 TopoJSON 加载和渲染

哎呀!这并不是我们预期的结果(但正如我们将看到的,这正是我们编码的结果)。为什么所有东西都聚集在一起?这是因为我们仍在使用默认的投影,即d3.geo.albersUsa()投影。

使用墨卡托投影创建世界地图

为了解决这个问题,我们只需要创建一个墨卡托投影对象,并将其应用到路径上。这是一个众所周知的投影,它将地球的地图渲染在一个矩形区域内。

这个过程在以下示例中得到了演示:

注意

bl.ock (12.7): goo.gl/IWQPte

这段代码中唯一的区别是路径设置的配置,使用墨卡托投影对象:

    var projection = d3.geo.mercator()
        .scale((width + 1) / 2 / Math.PI)
        .translate([width / 2, height / 2]);
    var path = d3.geo.path().projection(projection);

我们需要给投影对象提供一些关于我们渲染的宽度和高度的信息,现在得到的地图如下,看起来更像我们熟悉的世界地图:

使用墨卡托投影创建世界地图

使用正射投影创建球形地图

现在让我们将我们的投影更改为正射投影。这种投影将数据映射到一个模拟的球体上。以下示例展示了这一点:

注意

bl.ock (12.8): goo.gl/M464W8

这个示例只是通过使用d3.geo.orthographic()投影对象来改变之前的示例:

    var projection = d3.geo.orthographic();
    var path = d3.geo.path().projection(projection);

之前的示例代码给出了这个美丽的星球渲染:

使用正射投影创建球形地图

如果你仔细观察,你会注意到它并不完全完美。注意,澳大利亚似乎与非洲和马达加斯加相撞,新西兰在南大西洋中可见。

这是因为这个投影渲染了整个地球的 360 度,我们实际上是通过一个透明的地球看到了远侧陆地的大后方。

为了解决这个问题,我们可以使用墨卡托投影的.clipAngle()函数。参数是围绕中心点渲染陆地的度数。

以下示例展示了这一过程:

注意

bl.ock (12.9): goo.gl/G28ir0

这改变了代码中的一行:

var projection = d3.geo.orthographic()
   .clipAngle(90);

并给出了以下结果:

使用正射投影创建球形地图

书中提供的图像可能不明显,但网页上的地球仪图像相当小。我们可以使用投影的 .scale() 函数来改变渲染的缩放比例。默认的缩放值是 150,相应的值会使渲染更大或更小。

以下示例将地球仪放大一倍,同时设置地球仪的中心不被 SVG 容器裁剪:

注意

bl.ock (12.10): goo.gl/EVsHgU

var projection = d3.geo.orthographic()
    .scale(300)
    .clipAngle(90)
    .translate([width / 2, height / 2]); 

这种正射投影默认情况下将视图中心定位在地球仪的纬度和经度(0,0)。如果我们想定位在另一个位置,我们需要通过纬度和经度度数来 .rotate() 投影。

以下示例将地球仪旋转以突出显示美国:

注意

bl.ock (12.11): goo.gl/1acSjF

投影的一个变化如下:

var projection = d3.geo.orthographic()
    .scale(300)
    .clipAngle(90)
    .translate([width / 2, height / 2])
    .rotate([90, -40]);

这种投影方式的变化给我们带来了以下结果:

使用正射投影创建球形地图

调味地球仪

尽管这个地球仪使用创建它的代码量相当可观,但它感觉有点单调。让我们稍微区分一下国家,并添加纬线和经线。

地球仪上国家上色

我们可以使用 d3.scale.category20() 颜色比例尺来给地球仪上的国家上色。但我们不能简单地旋转颜色,因为相邻的国家可能会被填充成相同的颜色。

为了避免这种情况,我们将利用 TopoJSON 的另一个函数 topojson.neighbors()。此函数将返回给定一组几何形状(如国家),一个标识相邻几何形状的数据结构。然后我们可以利用这个数据来防止潜在的颜色问题。

该过程在以下示例中得到了演示:

注意

bl.ock (12.12): goo.gl/9UimER

本例中的投影保持不变。其余的代码已更改。

我们首先使用与上一个示例相同的投影,所以这里不重复代码。以下创建颜色、国家和邻居的数据结构:

var color = d3.scale.category20();
var countries = topojson.feature(world,
                         world.objects.countries).features;
var neighbors = topojson.neighbors(
                         world.objects.countries.geometries);

地球仪的创建使用以下语句:

var color = d3.scale.category20();
svg.selectAll('.country')
    .data(countries)
    .enter()
    .append('path')
    .attr('d', path)
    .style('fill', function (d, i) {
        return color(d.color = d3.max(neighbors[i],
            function (n) { 
                return countries[n].color; 
            })
            + 1 | 0);
    });

我们得到的地球仪如下:

地球仪上国家上色

看起来很棒!但仍然缺少纬线和经线,你实际上无法确定地球仪的范围。现在让我们通过添加纬线和经线来解决这个问题。

你会惊讶于添加纬线和经线有多容易。在 D3.js 中,这些被称为 graticules。我们通过实例化一个 d3.geo.graticules() 对象来创建它们,然后在国家的路径之前添加一个单独的路径。

这在以下示例中得到了演示:

注意

bl.ock (12.13): goo.gl/5eJOai

添加到先前示例中的唯一代码如下:

var graticule = d3.geo.graticule();
svg.append('path')
    .datum(graticule)
    .attr('d', path)
    .style({
        fill: 'none',
        stroke: '#777',
        'stroke-width': '.5px',
        'stroke-opacity': 0.5
    });

代码的变化导致以下结果:

为地球仪上的国家着色

哇!正如他们所说,简单易行!

向地图添加交互性

如果用户无法在地图上平移和缩放以改变焦点,并更仔细地查看事物,那么地图有什么用呢?幸运的是,由于 D3.js,这使得实现这一点变得非常简单。我们将查看三个不同的交互式地图示例:

  • 平移和缩放世界地图

  • mouseover时突出显示国家边界

  • 使用鼠标旋转地球仪

平移和缩放世界地图

为了演示世界地图的平移和缩放,我们将对我们的世界墨卡托投影示例进行一些修改。这些修改将用于使用鼠标滚轮进行缩放,并能够拖动地图将其移动到另一个中心。

这种地图代码版本的可能图像可能如下所示,它位于巴西东边,并放大了几个因子:

平移和缩放世界地图

在平移和缩放地图时,我们应该考虑以下一些因素:

  • 我们只能在两个范围之间进行缩放,这样我们不会缩放得太远以至于看不到地图,也不会缩放得太近以至于迷失在单个国家中

  • 我们只能将地图拖动到一定范围内,以确保它是约束的,不会从某个边缘拖动出去

示例可在以下位置找到:

注意

bl.ock (12.14): goo.gl/jjouGK

大部分代码是从墨卡托投影示例中复用的,并且还添加了代码来为各国着色。

创建主要 SVG 元素的不同之处在于允许拖动和缩放。这始于创建一个缩放行为,并将其分配给主要 SVG 元素。此外,由于我们需要缩放客户端元素,我们添加一个组来便于这一动作:

var zoom = d3.behavior.zoom()
    .scaleExtent([1, 5])
    .on('zoom', moveAndZoom);

var svg = d3.select('body')
    .append('svg')
    .attr({
        width: width,
        height: height
     })
    .call(zoom);
var mainGroup = svg.append('g');

代码的其余部分加载数据并渲染地图,与之前的示例相同。

moveAndZoom函数,它将在任何拖动和缩放事件上被调用,如下所示:

function moveAndZoom() {
    var t = d3.event.translate;
    var s = d3.event.scale;

    var x = Math.min(
        (width / height) * (s - 1),
        Math.max(width * (1 - s), t[0]));

    var h = height / 4;
    var y = Math.min(
        h * (s - 1) + h * s,
        Math.max(height * (1 - s) - h * s, t[1]));

    mainGroup.attr('transform', 'translate(' + x + ',' + y +
                                        ')scale(' + s + ')');
}

从这些值中,我们需要根据当前鼠标位置调整地图上的 SVG 平移,同时考虑到缩放级别。我们也不希望地图在任意方向上平移,这样地图和边界之间就有填充;这是通过Math.minMath.max的联合调用来处理的。

恭喜你,你现在有一个完全平移和扫描的地图!

注意

注意,当你放大时,国家的边界相当粗糙。这是由于数据的 110 米分辨率造成的。为了获得更精确的图形,请使用具有更细细节的文件。更好的是,根据缩放级别动态更改到更高分辨率的数据。

在鼠标悬停时突出显示国家边界

现在,让我们给我们的地图添加另一个交互效果:突出显示鼠标当前悬停在其几何形状上的国家边界。这将帮助我们强调用户当前正在检查的国家。以下是一个快速演示,其中秘鲁有一个细白的边界:

鼠标悬停时突出显示国家边界

示例可在以下位置找到:

注意

bl.ock (12.15): goo.gl/DTtJ2A

这是通过在先前的示例中做几处修改来实现的。修改从创建顶级组元素开始:

mainGroup.style({
    stroke: 'white',
    'stroke-width': 2,
    'stroke-opacity': 0.0
});

这段代码通知 D3.js,该组内包含的所有 SVG 元素都将有一个 2 像素的白色边框,最初是透明的。当我们悬停鼠标时,我们将使适当的几何形状可见。

现在,我们需要在每个代表国家的路径元素上连接鼠标事件处理器。在mouseover事件中,我们将stroke-opacity设置为不透明,并在鼠标退出时将其设置回透明:

mainGroup.selectAll('path')
    .on('mouseover', function () {
        d3.select(this).style('stroke-opacity', 1.0);
    });
mainGroup.selectAll('path')
    .on('mouseout', function () {
        d3.select(this).style('stroke-opacity', 0.0);
    });

每当缩放级别发生变化时,我们希望进行的一个小改动是。当缩放级别增加时,国家边界会不成比例地变厚。为了防止这种情况,我们可以在moveAndZoom函数的末尾添加以下语句:

g.style("stroke-width", ((1 / s) * 2) + "px");

这表示国家的边界应该始终保持在视觉上2px的厚度,无论缩放级别如何。

使用鼠标旋转地球

交互性也可以应用于其他投影。我们将检查使用鼠标旋转正交地球。示例可在以下位置找到:

注意

bl.ock (12.16): goo.gl/cpH0LN

为了节省一点空间,我们这里不会展示图片,因为它看起来与本章早期示例相同,只是它会跟随鼠标旋转。此外,旋转效果在打印介质中会丢失。

但这个工作方式非常简单。该技术涉及创建两个比例尺,一个用于经度,另一个用于纬度。经度是通过将鼠标位置从0映射到图形宽度到-180180度的经度。纬度是将垂直鼠标位置映射到90-90度:

var scaleLongitude = d3.scale.linear()
    .domain([0, width])
    .range([-180, 180]);

var scaleLatitude = d3.scale.linear()
    .domain([0, height])
    .range([90, -90]);

当鼠标移动到 SVG 元素上时,我们捕获它并将鼠标位置缩放为相应的纬度和经度;然后我们设置投影的旋转:

svg.on('mousemove', function() {
    var p = d3.mouse(this);
    projection.rotate([scaleLongitude(p[0]), 
                       scaleLatitude(p[1])]);
    svg.selectAll('path').attr('d', path);
});

这是一个相当酷的数学和比例技巧,它使我们能够看到整个地球上的每一个位置。

注释地图

我们使用地图的最终示例将展示如何在地图上添加注释。前两个示例将展示如何在地图上放置标签和标记,第三个示例将展示如何使用渐变色为地区着色,直至州级别。

如果我们不得不自己完成这些技术,通常需要一些相当复杂的数学,但幸运的是,D3.js 再次帮助我们只需几个语句就解决了这个问题。

使用质心标记状态

我们到目前为止创建的美国地图在内容上感觉有点不足,因为它们没有在其几何形状上放置州名。对于许多阅读地图的人来说,使名称可见将非常有帮助。示例可在以下位置找到:

注意

bl.ock (12.17): goo.gl/3vChcR

示例的结果如下:

使用质心标记州

这实际上相当容易实现,只需在我们的美国墨卡托投影示例中添加一个语句即可。以下代码放置在创建所有州边界的.selectAll()语句之后:

svg.selectAll('text')
    .data(data.features)
    .enter()
    .append('text')
    .text(function(d) { return d.properties.name; })
    .attr({
        x: function(d) { return path.centroid(d)[0]; },
        y: function(d) { return path.centroid(d)[1]; },
        'text-anchor': 'middle',
        'font-size': '6pt'
    });

该语句为数据文件中的每个几何特征创建一个文本元素,并将文本设置为几何对象的name属性的值。

文本的位置使用路径的函数来计算几何形状的质心。质心是几何形状的数学中心,可以使用路径的.centroid()函数来计算。

对于大多数州,尤其是矩形州,这效果很好。对于其他形状不规则的州,以密歇根州为例,放置可能不是从美学角度来看最理想的。有各种方法可以解决这个问题,但这些超出了本书的范围(提示:这涉及到为每个几何形状添加额外的数据来表示位置偏移)。

在特定地理位置放置符号

我们将要查看的最后一个地图示例是将 SVG 元素放置在地图上的特定坐标处。具体来说,我们将放置圆圈在 50 个人口最多的城市的位置,并使圆圈的大小与人口成比例。

我们将使用的数据在us-cities.csv文件中,该文件可在以下位置找到:gist.githubusercontent.com/d3byex/65a128a9a499f7f0b37d/raw/176771c2f08dbd3431009ae27bef9b2f2fb56e36/us-cities.csv。数据很简单;以下是一些前几行:

name,population,latitude,longitude
New York,8491079,40.6643,-73.9385
Los Angeles,3792621,34.0194,-118.4108
Chicago,2695598,41.8376,-87.6818

示例可在以下位置找到:

注意

bl.ock (12.18): goo.gl/Y9MN5q

结果的可视化如下:

在特定地理位置放置符号

上述示例利用了美国墨卡托示例代码。然而,此示例需要加载两个数据文件。为了方便起见,我们将使用由 Mike Bostock 创建的名为queue的库来异步加载这些文件,并在两个文件都加载完成后执行ready()函数。您可以在github.com/mbostock/queue获取此库和文档:

queue()
    .defer(d3.json, usDataUrl)
    .defer(d3.csv, citiesDataUrl)
    .await(function (error, states, cities) {

然后地图的渲染方式与前面的示例相同。然后我们需要放置圆圈。为此,我们需要将纬度和经度值转换为XY像素位置。我们可以在 D3.js 中使用投影对象来完成此操作:

svg.selectAll('circle')
    .data(cities)
    .enter()
    .append('circle')
    .each(function(d) {
        var location = projection([d.longitude, d.latitude]);
        d3.select(this).attr({
            cx: location[0],
            cy: location[1],
            r: Math.sqrt(+d.population * 0.00004)
        });
    })
    .style({
        fill: 'blue',
        opacity: 0.75
    });

对于创建的每个圆,此代码会调用投影函数,传递每个城市的纬度和经度。返回值是该位置的像素的xy坐标。因此,我们只需将圆的中心设置为这个结果,并给圆分配一个与人口规模成比例的半径。

创建渐变图

我们最后的地图示例是创建一个渐变图。渐变图是一种用不同颜色填充区域以反映基础数据值的地图,而不仅仅是用不同颜色来表示不同的地理边界。这些是相当常见的视觉类型,它们通常显示相邻地区之间的人口意见差异,或者经济因素如何在不同邻国之间有所不同。

示例可在以下位置找到:

注意

bl.ock (12.19): goo.gl/ZeTh4o

最终的可视化结果如下:

创建渐变图

此渐变图表示了 2008 年美国各县失业率。蓝色的阴影从较深(表示失业率较低)到较浅(表示失业率较高)变化。

失业率数据可在gist.githubusercontent.com/d3byex/65a128a9a499f7f0b37d/raw/176771c2f08dbd3431009ae27bef9b2f2fb56e36/unemployment.tsv找到。前几行如下所示:

id    rate
1001  .097
1003  .091
1005  .134
1007  .121
1009  .099
1011  .164

数据由一对县标识符和相应的失业率组成。县 ID 将与在gist.githubusercontent.com/d3byex/65a128a9a499f7f0b37d/raw/176771c2f08dbd3431009ae27bef9b2f2fb56e36/us.json中可用的us.json文件中的县 ID 相匹配。

此文件由描述美国所有县形状的 TopoJSON 组成,每个县在失业文件中都有相同的县 ID。此文件的片段如下,显示了用于渲染国家1001的弧线:

{
  "type": "Polygon",
  "id": 1001,
  "arcs": [ [ -8063, 8094, 8095, -8084, -7911 ] ]
},

我们的目标是将失业率量化,然后为每个几何形状填充一个与该分位数对应的颜色。实际上,这比看起来要容易得多。

在这个例子中,我们将失业率映射到十个分位数。每个的颜色将使用具有特定名称的样式指定。这些声明如下:

<style>
    .q0-9 { fill:rgb(247,251,255); }
    .q1-9 { fill:rgb(222,235,247); }
    .q2-9 { fill:rgb(198,219,239); }
    .q3-9 { fill:rgb(158,202,225); }
    .q4-9 { fill:rgb(107,174,214); }
    .q5-9 { fill:rgb(66,146,198); }
    .q6-9 { fill:rgb(33,113,181); }
    .q7-9 { fill:rgb(8,81,156); }
    .q8-9 { fill:rgb(8,48,107); }
</style>

数据是通过queue()函数加载的:

queue()
    .defer(d3.json, usDataUrl)
    .defer(d3.tsv, unempDataUrl, function(d) { 
                      rateById.set(d.id, +d.rate); 
     })
    .await(function(error, us) {

这段代码使用了一个用于失业数据的.defer()的替代形式,它为每个加载的数据项调用一个函数(队列的另一个酷特性)。这构建了一个d3.map()对象(类似于字典对象),它将县 ID 映射到其失业率,我们在渲染过程中使用这个映射。

县数据首先被渲染。为此,我们需要创建一个分位数刻度,它将域从0映射到0.15。这将用于将失业水平映射到一种样式。然后配置范围以生成九种样式的名称:

var quantize = d3.scale.quantize()
    .domain([0, .15])
    .range(d3.range(9).map(function(i) { 
        return 'q' + i + '-9'; 
}));

接下来,代码创建了一个albersUsa投影和一个相关的路径:

var projection = d3.geo.albersUsa()
    .scale(1280)
    .translate([width / 2, height / 2]);

var path = d3.geo.path()
    .projection(projection);

下一步是创建一个组来存放阴影县,然后,我们将通过绑定到counties特征为这个组添加每个县的路径:

svg.append('g')
    .attr('class", "counties")
    .selectAll("path")
    .data(topojson.feature(us, us.objects.counties).features)
    .enter()
    .append("path")
    .attr("class", function(d) { 
        return quantize(rateById.get(d.id)); 
     })
    .attr("d", path);

最后,我们使用白色描边叠加了州的轮廓,以帮助我们区分州界:

svg.append('path')
    .datum(topojson.mesh(us, us.objects.states)
    .attr({
        'class': 'states',
        fill: 'none',
        stroke: '#fff',
        'stroke-linejoin': 'round',
        'd': path
    });

注意

这段代码还使用了topojson.mesh函数从 TopoJSON 对象中提取所有州的MultiPolygon(GeoJSON)数据。

就这样!我们已经创建了一个渐变图,并使用了一种易于与其他类型的数据重复使用的编码模式。

摘要

我们以简要了解 GeoJSON 和 TopoJSON 开始这一章。如果您在 D3.js 中做任何与地图相关的事情,您将使用其中之一或两个。我们只介绍了足够的内容,以便理解其结构以及如何使用它来定义可以渲染为地图的数据。

从那里,我们深入创建了几张地图,并涵盖了您在创建过程中将使用到的许多概念。这包括加载数据、创建投影以及渲染数据中的几何形状。

我们研究了两种投影,墨卡托和正射投影,以了解这些如何呈现数据。在这个过程中,我们还探讨了如何样式化地图上的元素,用颜色填充几何形状,以及在鼠标悬停时突出显示几何形状。

然后,我们研究了如何用标签以及基于数据的颜色元素(渐变图)来注释我们的地图,并在特定的地理位置放置符号,其大小基于数据。

到这本书的这一部分,我们已经相当详细地介绍了 D3.js 的核心内容,至少足够让您能够熟练使用它。但我们也只创建了独立的可视化,这些可视化不与其他可视化交互。

在下一章,这本书的最后一章,我们将探讨如何使用 AngularJS 结合多个 D3.js 可视化,以及这些可视化如何对用户在其页面上操作其他内容做出反应。

第十三章. 结合 D3.js 和 AngularJS

本书最后一章将演示在单个网页上使用多个 D3.js 视觉元素。这些示例还将展示以模块化方式构建 D3.js 视觉元素,这允许通过简单的 HTML 标签进行重用,同时将数据从渲染视觉的代码中抽象出来。这将使创建更通用的 D3.js 视觉元素成为可能,这些元素可以通过单个 HTML 标签放置在页面上,并且与数据源松散耦合。

为了实现这些功能,我们将利用AngularJS,这是一个用于创建动态和模块化 Web 应用的 JavaScript 框架。示例将展示如何集成 AngularJS(v1.4)和 D3.js 以创建可重用和互操作的视觉元素。对本章来说,预期读者对 AngularJS 有初步了解,但重点将放在如何使用 AngularJS 的功能来创建可重用和可扩展的 D3.js 控件上;因此,即使是 AngularJS 的新手也能跟上。

在本章中,我们将通过以下主题来实现这一目标:

  • 组合可视化概述

  • 使用 AngularJS 应用、控制器和指令创建条形图

  • 添加一个指令到页面上添加饼图

  • 在视觉元素之间添加详细视图和交互性

  • 在数据细节修改时更新图表

组合可视化概述

在深入示例之前,让我们先检查最终结果,以帮助理解我们将使用 AngularJS 结合 D3.js 尝试实现的一些目标。以下图表示了最终交互式和组合图表的静态图像:

组合可视化概述

页面的每个组件——条形图、饼图和输入表单——最初将独立构建,并且能够独立运行。为此,示例将使用 AngularJS 的功能来促进以下功能:

  • 每个视觉元素都应该用 HTML 标签简单地表示,而不是将每个代码复制到页面上。这是通过 AngularJS 指令完成的。

  • 我们不会在每个视觉元素中一次性在代码中加载数据,而是利用一个跨每个元素共享的应用级数据模型。在 AngularJS 中,这是通过创建一个 JavaScript 数据模型并将其注入到每个指令的控制器中实现的。

  • 条形图将提供一个方式来暴露当前选中项的更新通知,这样详细模型就可以更新其数据。这将通过模型中的selectedItem属性实现,该属性允许详细指令通过 AngularJS 模板绑定来监视更新。

  • 此外,当应用模型在详细指令中更新时,条形图和饼图将通过 AngularJS 通知更新,以表示修改。

注意

本章与之前章节的一个不同之处在于,代码不在 bl.ock.org 或 JSBIN.COM 上在线提供,必须从 Packt 网站获取。这是因为示例使用了 AngularJS,它与 bl.ock.org 和 JSBIN.COM 的兼容性不是很好。因此,代码必须从本地通过 Web 服务器运行。你可以简单地解压代码并将其放置在 Web 服务器的根目录中,或者在你的内容根目录下启动你选择的 Web 服务器。每个示例都作为文件夹根目录中的一个不同的 HTML 文件实现,并且每个文件都引用了各种子目录中的多个其他文件。

使用 AngularJS 创建条形图

第一个示例将创建一个可重用的条形图组件来演示如何使用底层控制器创建 AngularJS 指令。这是在 HTML 文件01_just_bars.html中实现的,它包含以下组件:

  • AngularJS 应用对象:这个对象在页面中(即在app.js中)作为 AngularJS 代码的入口点。

  • 一个 AngularJS 控制器(在controllers/basic_dashboard.js中):这个控制器创建数据并将其发送到渲染图形 HTML 代码的指令

  • 指令:这个指令在directives/bars.js中渲染 D3.js 条形图。

网页和应用

AngularJS 应用通过网页呈现给用户,网页首先加载 AngularJS 和 D3.js 库(这在本章的所有示例中都很常见)。看看下面的代码:

<script src="img/angular.min.js"></script>
<script src="img/d3.v3.min.js" charset="utf-8"></script>

然后,页面加载 AngularJS 应用对象、指令和控制器实现。现在,执行以下代码:

<script src="img/app.js"></script>
<script src="img/bars.js"></script>
<script src="img/basic_dashboard.js"></script>

这些细节将在稍后进行考察。在我们查看这些之前,这个文件中剩余的 HTML 代码使用一个带有ng-appng-controller属性的<div>标签创建 AngularJS 应用和我们的指令控制器。添加以下代码:

<div ng-app="dashboardApp" ng-controller="dashboardController">
    <bars-view width="500" height="105"></bars-view>
</div>

使用ng-app属性告诉 AngularJS 在哪里找到实现,这是一个名为dashboardApp的模块(即 AngularJS 可引用的 JavaScript 片段)。

在这个示例中,这个模块在app.js中声明(每个示例都是这样):

angular.module('dashboardApp', []);

这个示例实际上并没有为应用模块声明任何代码,它仅仅是一个 HTML 标记可以进入 AngularJS 并开始定位各种对象的地方。在一个更复杂的应用中,这将是注入其他依赖模块和进行一些应用级初始化的好地方。

这个<div>标签内的标签定义了一个称为 AngularJS 指令的结构。这个指令渲染控制器中代表的数据。在我们查看指令的实现之前,让我们看看提供数据给指令的控制器。

控制器

<div> 标签上的 ng-controller 属性指定了一个用于向此 <div> 标签的子元素指定的 AngularJS 指令提供数据的控制器名称。AngularJS 在 ng-app 指定的模块中搜索具有指定名称的控制器。在这个例子中,这个控制器在 controllers/basic_dashboard.js 中声明,如下所示:

angular.module('dashboardApp')
    .controller('dashboardController', 
        ['$scope', function ($scope) {
            $scope.items = [
                { Name: 'Mike', Value: 49 },
                { Name: 'Marcia', Value: 52 },
                { Name: 'Mikael', Value: 18 }
        ];
    }]);

这使用 .controller() 创建了一个名为 dashboardController 的 AngularJS 控制器,它是应用程序的 dashboardApp 模块的一部分。看看下面的脚本:

angular.module('dashboardApp')
       .controller('dashboardController', 
                   ['$scope', function ($scope) {

.controller() 的第二个参数是一个数组,指定了要注入到实现控制器的函数中的变量以及实现控制器的函数。

现在,这告诉 AngularJS 我们希望将代表控制器数据和将被注入到控制指令中的 AngularJS 变量 $scope 传递给这个要初始化的函数。

在以下命令的最后一个语句中,通过向作用域中添加一个项目的属性来声明要提供给视图的数据:

$scope.items = [
    { Name: 'Mike', Value: 49 },
    { Name: 'Marcia', Value: 52 },
    { Name: 'Mikael', Value: 18 }
]; 

柱状图的指令

AngularJS 指令是一个自定义的 HTML 标签,它指导 AngularJS 如何根据控制器提供的数据创建 HTML。在示例的 HTML 代码中,有一个名为 <bars-view> 的标签被声明。当页面加载时,AngularJS 会检查 HTML 中的所有标签,如果某个标签不被识别为标准 HTML 标签,AngularJS 会搜索你作为应用程序的一部分声明的指令,以提供这个标签的实现。

在这种情况下,它将标签的连字符名称 <bars-view> 转换为驼峰式版本 barsView,并在具有此名称声明的模块中查找指令。如果找到,AngularJS 将执行为指令提供的代码以生成 HTML 代码。

在这个例子中,AngularJS 找到了在 directives/bars.js 文件中实现的 <bars-view> 标签。此文件首先通知 AngularJS 我们希望在 dashboardApp 模块中声明一个名为 barsView 的指令:

angular.module('dashboardApp')
    .directive('barsView', function () {
        return {
            restrict: 'E',
            scope: { data: '=' },
            link: renderView
        };

.directive() 的第二个参数是一个函数,它告诉 AngularJS 如何应用和构建视图。在这个例子中,指定了三个指令:

  • restrict: 'E':这告诉 AngularJS 此指令仅适用于 HTML 元素,而不适用于它们的属性或 CSS 类名。

  • scope: { data: "="}:这告诉 AngularJS 我们希望在作用域中的数据和视图中的元素之间建立双向绑定。如果控制器中的数据发生变化,AngularJS 将更新视图,反之亦然。

  • link: renderView:这个属性告诉 AngularJS 当视图创建时将调用哪个函数。然后这个函数将生成 DOM 构造来表示视图。这就是我们将放置我们的 D3.js 代码的地方。

renderView 函数声明如下:

function renderView($scope, $elements, $attrs) {

当 AngularJS 调用此函数来渲染指令的标签时,它将表示相关控制器的范围对象作为$scope参数传递。第二个参数$elements传递一个 AngularJS 对象,可以用来识别指令应附加新元素的顶层 DOM 元素。最后一个参数$attrs传递任何在先前参数的根 DOM 元素中定义的自定义属性。

实现条形图的代码与我们的早期条形图示例没有显著不同。它所做的第一件事与 AngularJS 不同,因为它从函数中传递的范围获取数据,如下所示:

var data = $scope.$parent.items;

<bars-view>指令由 AngularJS 分配一个范围对象。控制器的数据实际上是此对象的parent范围属性的属性。此对象具有我们定义在控制器中的items属性及其相关数据作为items属性。

元素的宽度和高度,如 HTML 代码中指定的,可以使用$attrs参数的widthheight属性检索。看看以下命令:

var width = $attrs.width, height = $attrs.height;

在获取宽度和高度后,我们可以创建图表的主要 SVG 元素。这将附加到$element[0],它代表此指令的根 DOM 元素($element对象实际上是 AngularJS 的一个包装根元素的实例,使用[0]索引器访问),如下所示:

var svg = d3.select($element[0])
    .append("svg");

代码的其余部分与前面章节中涵盖的示例类似,用于创建带有叠加文本的条形图。它首先设置 SVG 元素的大小,并设置计算条形大小和位置所需的各种变量,如下所示代码:

svg.attr({
    width: width,
    height: height
});

var max = d3.max(data, function(d) {
    return d.Value;
});

var colors = d3.scale.category20();

var barHeight = 30;
var leftMargin = 15;
var barTextOffsetY = 22;

然后创建条形,并设置为动画到它们各自的最大大小。看看以下:

svg.selectAll('rect')
    .data(data)
    .enter()
    .append('rect')
    .attr({
        height: barHeight,
        width: 0,
        x: 0,
        y: function(d, i) {
            return i * barHeight;
        },
        stroke: 'white'
    })
    .style('fill', function(d, i) {
        return colors(i);
    })
    .transition()
    .duration(1000)
    .attr('width', function(d) {
        return d.Value / (max / width);
    });

现在,在更新场景中,所有现有的 D3.js 元素都被选中,这会将任何现有条形的大小过渡到新的大小。看看以下代码:

svg.selectAll("rect")
    .data(data)
    .transition()
    .duration(1000)
    .attr("width", function(d, i) {
        return d.Value / (max / width);
    });

然后,实现创建条形上的进入标签以及在数据值更改时更改条形上的文本的情况,如下所示:

 svg.selectAll('text')
    .data(data)
    .enter()
    .append('text')
    .attr({
        fill: '#fff',
        x: leftMargin,
        y: function(d, i) {
            return i * barHeight + barTextOffsetY;
        }
    })
    .text(function(d) { 
        return d.Name + ' (' + d.Value + ')'; 
    });

svg.selectAll('text')
    .data(data)
    .attr({
        fill: '#fff',
        x: leftMargin,
        y: function(d, i) {
            return i * barHeight + barTextOffsetY;
        }
    })
    .text(function(d) {
        return d.Name + ' (' + d.Value + ')';
    });
}

在浏览器中打开此页面时,显示以下图表:

条形图的指令

为甜甜圈添加第二个指令

下一个示例添加第二个 D3.js 可视化来表示数据中的甜甜圈图。此实现需要创建一个新的指令并将其添加到网页上。它重用了控制器实现以及它创建的数据。

网页

此示例的网页可在02_bars_and_donut.html中找到。与之前的网页相比,它包含一个额外的甜甜圈视图。看看以下:

<script src="img/app.js"></script>
<script src="img/bars.js"></script>
<script src="img/donut.js"></script>
<script src="img/basic_dashboard.js"></script>

页面内容的声明现在变为以下内容:

<div ng-app="dashboardApp" ng-controller="BasicBarsController">
    <bars-view width="500" height="105" 
              style="display: table-cell; vertical-align: middle">
    </bars-view>
    <donut-view width="300" height="300" 
                style="display: table-cell">
    </donut-view>
</div>

这为 donut-view 添加了一个额外的指令。还向指令添加了样式,使它们并排浮动。

甜甜圈图的指令

甜甜圈指令的实现首先声明这个指令将被添加到 dashboardApp 模块中,并且它的名字将是 donutView(因此我们在 HTML 代码中使用 <donut-view>)。与条形图指令一样,它还指示 AngularJS 仅将此代码应用于 DOM 元素,具有双向数据绑定,并由名为 renderView 的函数实现;请看以下代码:

angular.module('dashboardApp')
    .directive('donutView', function () {
        return {
            restrict: 'E',
            scope: { data: '=' },
            link: renderView
        };

renderView 的这个版本遵循与 bars-view 实现相似的图案。它首先从作用域中获取数据,包括视觉的宽度和高度,并计算甜甜圈的半径。以下代码被执行:

function renderView($scope, $elements, $attrs) {
    var data = $scope.$parent.items;

    var width = $attrs.width,
        height = $attrs.height,
        radius = Math.min(width, height) / 2;

然后使用饼图布局开始渲染甜甜圈,如下所示:

var pie = d3.layout.pie()
    .value(function (d) { return d.Value; })
    .sort(null);

弧线填充在 SVG 元素边界外 1070 像素之间,这是基于计算出的半径。请看以下代码:

    var arc = d3.svg.arc()
        .innerRadius(radius - 70)
        .outerRadius(radius - 10);

然后,通过以下方式将主 SVG 元素附加到 $elements[0] 上,开始构建视觉:

    var svg = d3.select($elements[0])
        .append('svg')
        .attr({
            width: width,
            height: height
    });

最后,使用颜色尺度和每个进入数据的路径生成器构建甜甜圈图的视觉元素,如下所示:

var colors = d3.scale.category20();
graphGroup
    .datum(data)
    .selectAll('path')
    .data(pie)
    .enter()
    .append('path')
    .attr('fill', function(d, i) {
        return colors(i);
    })
    .attr('d', arc)
    .each(function(d) {
        this._current = d;
    });

在浏览器中加载此页面时,它呈现以下视觉效果,现在在单个网页上显示了两个 D3.js 视觉效果:

甜甜圈图的指令

添加细节视图和交互性

下一个示例向页面添加了一个细节指令,并增加了交互性,使得当点击一个条形时,细节指令将显示所选条形的适当数据。

为了实现这种交互性,条形图指令被修改,以便产生一个可以被 AngularJS 应用程序的其他部分监控的动作。这个动作将是设置模型上的 selectedItem 属性,其他控制器或指令可以监视这个属性的变化,然后采取行动。

网页

此示例的网页包含在 03_with_detail.html 中。包含的内容略有不同,因为我们将在 directives/bars_with_click.js 中包含我们 <bars-view> 指令的新实现,并在 controllers/enhanced_controller.js 中包含控制器,以及在 directives/detail.js 中包含代表细节视图的新指令。请看以下内容:

<script src="img/app.js"></script>
<script src="img/bars_with_click.js"></script>
<script src="img/donut.js"></script>
<script src="img/detail.js"></script>
<script src="img/enhanced_controller.js">
</script>

<div> 标签的声明略有变化,如下所示,通过添加 details-view 指令:

<div ng-app="dashboardApp" ng-controller="dashboardController">
    <bars-view width="500" height="105" 
        style="display: table-cell; vertical-align: middle">
    </bars-view>
    <donut-view width="300" height="300" 
        style="display: table-cell"></donut-view>
    <details-view data="selectedItem" width="300">
    </details-view>
</div>

注意,这个新指令使用一个名为 data 的属性,并将其值设置为selectedItem。这是一个特殊的 AngularJS 属性/绑定,指定了此指令的模型数据位于 DOM 层次结构中最近的 scope 对象的selectedItem属性中。在这种情况下,它是 div 标签上定义的 scope,并且每当此属性在 scope 中更改时,此指令将自动更新其数据和可视化。

在控制器中指定初始的selectedItem

详细视图控制器期望能够访问模型中的selectedItem属性以用作其数据,因此它需要为此属性设置一个初始值。以下添加了一行代码来完成此任务:

angular.module('dashboardApp')
    .controller('dashboardController',
                ['$scope', function ($scope) {
        $scope.items = [
            { Name: 'Mike', Value: 49 },
            { Name: 'Marcia', Value: 52 },
            { Name: 'Mikael', Value: 18 }
        ];
        $scope.selectedItem = $scope.items[0];
    }]); 

修改后的条形图视图指令

<bars-view>指令随后添加了一个点击处理程序,以便在点击条形图时设置选中项的值,如下所示:

.on('click', function (d, i) {
    $timeout(function () {
        parent.selectedItem = d;
    };
}) 

此点击处理程序执行一个动作:它将父作用域中选中项的值更新为点击的视觉元素下方的数据项的值。它不会向其他组件发送消息,也不应该这样做。如果其他指令对此更新感兴趣,它们可以通过查找模型中的更改来执行此操作。

注意

这被包裹在调用 AngularJS 的$timeout函数中,该函数将根据此属性的更改更新浏览器上的 UI。如果不执行此操作,任何感兴趣的元素都不会被 AngularJS 通知。

实现详细视图指令

详细视图是一段相当简单的代码,从指令声明开始。看看以下内容:

angular.module('dashboardApp')
    .directive('detailsView', function () {
        return {
            restrict: 'E',
            scope: { data: "=" },
            templateUrl: 'templates/static_item.html'
        };
    });

与我们的其他指令声明相比,这个声明中的不同之处在于代码没有指定link属性,而是指定了templateUrl属性及其相关值。这告诉 AngularJS,这个指令将不会通过调用 JavaScript 函数来实现,而应该使用templates/static_item.html文件中的内容。该文件的内容如下:

Name: {{data.Name}}
<br/>
Value: {{data.Value}}

这个 HTML 代码将由 AngularJS 注入到 DOM 中。该 HTML 包含嵌入的handlebars语法,AngularJS 会注意到并替换其内容。在这种情况下,将使用由指令的数据属性指定的对象的NameValue属性的值,其中数据是模型中selectedItem的绑定值,即当前选中的条形图。每当此属性更新时,AngularJS 将自动代表我们正确更新 DOM,而无需任何额外的编码。

生成的交互式页面

以下图像是此页面可能生成的显示示例:

生成的交互式页面

在此图像中,点击了第二个条形图,因此详细视图显示了此条形图的数据。当你点击不同的条形图时,详细中的值会相应地更改。

在详细数据修改时更新图表

最终的示例将使详细视图与条形图和甜甜圈图之间的数据更新双向。上一个示例仅在点击条形图时更新详细视图。详细视图的内容是静态文本,因此用户无法修改数据。这是通过修改模板以利用文本输入字段来改变的。控制器没有变化,所以不会进行讨论。

网页

此示例的网页 04_dynamic.html 与上一个示例相比有几个小的变化,以引用新的条形图、甜甜圈和详细指令的实现。<div> 标签保持不变。请看以下代码:

<script src="img/app.js"></script>
<script src="img/bars_with_click_and_updates.js"></script>
<script src="img/donut_with_updates.js"></script>
<script src="img/dynamic_detail.js"></script>
<script src="img/enhanced_controller.js">
</script>

修订后的条形图视图指令

新的 <bar-view> 指令有一个行为变化,同时还有一个小的结构变化。这个行为变化是监视传递给它的作用域中 selectedItem 属性的变化。为此,在 renderView() 代码的顶部附近添加了以下语句:

parent.$watch("selectedItem", render, true);

这通知 AngularJS 我们希望它监视绑定作用域对象中 selectedItem 属性的变化。当此属性或此对象的任何属性发生变化时(由第三个参数中的 true 指定),AngularJS 将调用 render() 函数。

注意

注意,此监视过程不必在详细视图控制器中执行,因为模板和 handlebars 的使用会自动设置此操作。

在选择 svg 元素并设置其大小之后,对代码的结构变化进行了修改。现在创建视觉效果的代码被包裹在新的 render() 函数中,该函数在指令首次加载时调用,然后在 selectedItem 的值每次发生变化时调用。当后者发生时,条形图被更新,条形动画到新的尺寸,并且它还修改了文本标签。

修订后的甜甜圈视图指令

与对 bar-view 指令的更新类似,此指令通过添加一个监视作用域中 selectedItem 属性的调用以及将渲染代码包裹在 updatePath() 函数中来修改,当此属性的值发生变化时可以调用此函数,如下所示:

parent.$watch('selectedItem', updatePath, true);

updatePath() 函数只需要为每个弧段重新生成路径,如下面的代码所示:

function updatePath() {
    path = path.data(pie);
    path.transition()
        .duration(750)
        .attrTween('d',
            function() {
                var i = d3.interpolate(this._current, a);
                this._current = i(0);
                return function(t) {
                    return arc(i(t));
                };
            });
}

详细视图指令

新的 <detail-view> 指令有一个修改,即使用不同的模板。请看以下代码:

angular.module('dashboardApp')
    .directive('detailsView', function () {
        return {
            restrict: 'E',
            scope: { data: "=" },
            templateUrl: 'templates/dynamic_item.html'
        };
    });

此模板的内容指定输入框而不是文本字段,如下所示:

Name: <input type="text" ng-model="data.Name"/>
<br/>
Value: <input type="text" ng-model="data.Value"/>

注意,对于更新 handlebars 的输入字段,不能使用这些符号。为了使这生效,您需要使用 AngularJS 的 ng-model 属性,并将其指向绑定数据对象及其相应的属性。

结果

以下截图显示了此示例的实际操作:

结果

在这个演示中,点击了第三个条形图,现在details-view提供了编辑控件,允许我们更改值。Mikael的值随后被更改为25,条形图和饼图动画显示值的变化。

这里真正令人愉快的一点是,实际上,在两个输入字段中逐个按键,AngularJS 会更新这些属性,并且条形图和饼图会在每个按键时更新!

摘要

本章的示例展示了如何使用 AngularJS 制作模块化和复合的 D3.js 可视化。它们首先展示了如何在 AngularJS 控制器中放置数据并与多个 D3.js 可视化共享。接下来,我们演示了如何将单个控制器中的数据共享到多个指令中。最后的两个示例展示了如何使用共享属性进行双向通信,并实现一个详情视图以允许编辑数据。

这本书通过示例介绍了使用 D3.js 的方法。本书从 D3.js 的基本概念开始,讲解如何使用其结构绑定数据并从中生成 SVG。在此基础上,我们通过向示例中添加功能,逐步展示了同一章节内以及章节之间逐渐复杂化的结构扩展。最终,这些示例涵盖了 D3.js 中的许多概念,可以帮助您从新手成长为能够构建丰富、交互式和复合可视化的人,这一切都是通过示例实现的。

posted @ 2025-09-26 22:08  绝不原创的飞龙  阅读(12)  评论(0)    收藏  举报