D3-js-数据可视化-全-
D3.js 数据可视化(全)
原文:
zh.annas-archive.org/md5/bd6fb82c4a05403e09666ef2f73f1d3b
译者:飞龙
前言
在自学 d3.js 时,常常有一种感觉:步骤 1:画两个圆圈,步骤 2:画剩下的猫头鹰。这本书试图弥合这个差距。
它使用完整的示例,从页面上的基本形状到完整的示例,循序渐进。这里没有魔法,没有步骤被遗漏未解释。你将理解制作可视化所需的 d3.js 的每一个细节。
我们将涉及从操作数据使其更容易处理到使用高级功能将绘图与计算坐标分离的各个方面。
这本书涵盖了什么内容
第一章, d3.js 入门,提供了一个简单的示例来展示 d3.js 的基础知识,并帮助你设置一个常用的环境,该环境在本书的其余部分中使用。
第二章, DOM、SVG 和 CSS 入门,详细解释了如何使用 d3.js 来操作页面上的内容,特别关注 SVG 和创建图像的核心工具。
第三章, 使数据有用,展示了如何以功能方式操作数据,从外部源加载数据,并使用 d3.js 的内置工具来避免繁琐的编码。
第四章, 让事物动起来,讨论了使用 d3.js 进行动画化可视化和允许用户与你的图像交互。
第五章, 布局 – d3 的黑色魔法,解释了 d3.js 布局的工作原理,并展示了如何使用相同的 dataset 获取截然不同的图像。那些花哨的可视化将不再看起来像魔法。
第六章, 设计良好的可视化,审视了来自网络的一些优秀可视化示例,并讨论了是什么让它们变得出色。
你需要这本书什么
你不需要太多东西来跟随示例。一个针对 Web 开发的机器将拥有所有东西。
我们在示例中假设了 Chrome 浏览器,但一切应该在 Safari、Firefox 和 Internet Explorer 版本 10 及以上中工作。特定的浏览器只影响你的调试工具的工作方式,但它们在所有浏览器中都非常相似。
我们还使用 Python 运行一个小型服务器。如果你使用 Mac 或 Linux,Python 已经安装好了;否则,你需要获取一个版本。我们用 Python 做的唯一一件事就是运行一个命令。
最后,你需要一个文本编辑器。我个人喜欢 Emacs,但 Sublime 和 Notepad++也是流行的选择。是的,你也可以使用 Vim。
这本书适合谁
这本书适合所有尝试自学 d3.js 的人,看过一些示例后想:“这到底是什么魔法?”。
本书假设你之前已经编写了一些 JavaScript,对一般 Web 开发相对熟悉,对编程基础有牢固的掌握,并且之前已经查看过 d3.js 示例。到本书结束时,你将能够理解甚至最复杂的可视化代码。
习惯用法
在本书中,你会发现许多不同类型信息的文本风格,以区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称按照以下方式显示:“在最后,我们将包括一个code.js
文件,我们将把大部分代码放在里面”。
代码块设置如下:
data = d3.keys(data).map(function (key) {
return {bucket: Number(key),
N: data[key]};
});
任何命令行输入或输出都按照以下方式编写:
> topojson -o water.json ne_50m_rivers_lake_centerlines.shp ne_50m_ocean.shp
> topojson -o land.json ne_50m_land.shp
> topojson -o cultural.json ne_50m_admin_0_boundary_lines.shp ne_10m_urban_areas.shp
新术语和重要词汇以粗体显示。你在屏幕上看到的,例如在菜单或对话框中的文字,在文本中显示如下:“你可以在下载标签中找到它们”。
注意
警告或重要注意事项以如下方框显示。
小贴士
小技巧和技巧看起来是这样的。
读者反馈
我们欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或可能不喜欢什么。读者反馈对我们开发你真正能从中获得最大收益的标题非常重要。
要向我们发送一般反馈,只需发送一封电子邮件到<feedback@packtpub.com>
,并在邮件主题中提及书名。
如果你在一个领域有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在你已经是 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助你从购买中获得最大收益。
下载示例代码
你可以从你购买的所有 Packt 书籍的账户中下载示例代码文件。www.packtpub.com
。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support
并注册,以便直接将文件通过电子邮件发送给你。
下载本书的颜色图像
我们还为你提供了一个包含本书中使用的截图颜色图像的 PDF 文件。你可以从www.packtpub.com/sites/default/files/downloads/0007OS_Images.pdf
下载此文件。
错误清单
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以避免其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata
,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站,或添加到该标题的勘误部分下的现有勘误列表中。您可以通过选择您的标题从 www.packtpub.com/support
查看任何现有勘误。
盗版
在互联网上对版权材料的盗版是所有媒体中持续存在的问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何我们作品的非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com>
联系我们,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和提供有价值内容方面的帮助。
问题
如果您在本书的任何方面遇到问题,可以通过 <questions@packtpub.com>
联系我们,我们将尽力解决。
第一章. d3.js 入门
在本章中,我将向你展示在 d3.js 中制作简单可视化的基本工具,而不会深入探讨,这样你就可以立即开始。我们将学习 d3.js 的基本语言及其规则。
我们将尝试创建坐标轴和自动缩放图形以适应视口,并学习在进入全面编程狂欢之前使用 Chrome 开发者工具来模拟我们的代码。通过本章,我们将设置整本书中使用的环境,并创建一个由我从 GitHub 创建的数据集的动画图表。
什么是 d3.js?
D3 这个名字代表数据驱动文档。迈克·博斯特克自 2011 年以来一直在公开开发这个强大的数据可视化库。它通过操作数据来帮助你绘制美丽的图形,无需过多担心像素位置,计算事物在图上的位置等问题。如果你曾经使用 Python 或类似语言可视化数据,你可能已经使用过类似gnuplot的工具。我向你保证,d3.js 提供了一种更加愉悦的体验。
官方网站d3js.org展示了众多展示 d3.js 强大功能的优秀示例,但理解它们至多只是有点棘手。完成本书后,你应该能够足够了解 d3.js,以理解这些示例。如果你想更密切地跟踪 d3.js 的发展,源代码托管在 GitHub 上,网址为github.com/mbostock/d3
。
精细的控制和其优雅性使 d3.js 成为最强大,如果不是最强大的开源可视化库之一。这也意味着它不太适合简单的任务,如绘制一个图表或两个——在这种情况下,你可能想使用专门为图表设计的库。尽管如此,许多人仍然在内部使用 d3.js。
作为数据操作库,d3.js 基于函数式编程原则,这可能是很多困惑的根源。不幸的是,函数式编程超出了本书的范围,但我将解释所有相关的部分,以确保每个人都在同一页面上。
设置播放环境
D3 结合 HTML、CSS 和 SVG 来创建图形。这意味着我们需要一个 HTML 文件和一个 JavaScript 文件。我们将使用 Chrome 开发者工具来调整我们的可视化并测试事物。让我们从一些 HTML 编码开始:
<!DOCTYPE html>
<title></title>
<link href="bootstrap/css/bootstrap.min.css" rel="stylesheet">
<div id="graph"></div>
<script src="img/d3.v3.min.js"></script>
<script src="img/code.js"></script>
这六行 HTML 代码是我们将在整本书中使用的基础知识。
前两行构成了一个最小的 HTML5 文档。你不再需要包含<html>
、<head>
和<body>
标签。接下来是<link>
标签,它引入了 Twitter Bootstrap 的 CSS 规则——一组很好的默认规则,可以使事物看起来更美观。然后是<div>
标签,它将包含我们的可视化,最后是<script>
标签,它加载 d3.js。
最后,我们包括一个code.js
文件,我们将把大部分代码放在这里。Twitter 不提供 Bootstrap 的托管版本,所以您必须从twitter.github.com/bootstrap/
下载整个包,并将其解压到您正在使用的其他文件旁边。我们现在只需要一个服务器来运行一切。这是因为我们不希望在制作 Ajax 请求时与浏览器安全模型发生冲突。任何服务器都可以,但如果您已经安装了 Python(默认在 Mac 和 Linux 上),这是一个快速启动和运行的方法。
启动控制台,导航到您的工作目录,并运行以下命令:
$ python -m SimpleHTTPServer
Python 将以独立脚本的形式运行SimpleHTTPServer
模块,并创建一个功能齐全的本地服务器。
现在将 Chrome 指向localhost:8000
并启动开发者控制台——Linux 和 Windows 的Ctrl + Shift + J,Mac 的Option + Command + J。您应该看到一个空白的网站和一个空白的 JavaScript 控制台,其中有一个命令提示符等待输入代码:
Chrome 开发者工具快速入门
Chrome 开发者工具在 Web 开发中是必不可少的。大多数现代浏览器都有类似的功能,但我认为我们还是坚持使用一个例子来使这本书更简洁。您可以使用不同的浏览器。
我们主要将使用元素和控制台标签:元素用于检查 DOM,控制台用于与 JavaScript 代码交互并查找任何问题。
其他六个标签对于大型项目非常有用。网络标签会告诉您文件加载需要多长时间,并帮助您检查 Ajax 请求。配置文件标签可以帮助您对 JavaScript 进行性能分析。资源标签适合检查客户端数据。老实说,我以前从未需要过时间线和审计。开发者工具中我最喜欢的功能之一是位于元素标签右侧的 CSS 检查器。
它可以告诉您哪些 CSS 规则影响了元素的风格,这对于寻找破坏事物的违规规则非常有用。您还可以编辑 CSS 并立即看到结果:
一个简单的直方图
我们将通过创建一个表示 GitHub 用户提交代码时间的直方图来介绍 d3.js 的基础知识。我们将标注坐标轴,确保可扩展性,并修改动画以增加额外的魅力。
数据集包含 504,015 个仓库,我花费了一周时间从每个仓库的穿孔卡片数据中创建它。穿孔卡片只是一个 7 x 24 的桶状网格,指定了在特定一天和小时内发生的提交次数。数据集的直方图摘要托管在nightowls.swizec.com/data/histogram-hours.json
,并将小时映射到该小时内发生的提交总数。
这就是我们想要达到的效果:
我们首先从上一节准备好的环境中添加一些关于中心 <div>
标签的代码:
<div class="container">
<div class="row">
<div id="graph" class="span12"></div>
</div>
</div>
额外的 <div>
标签使图表水平居中,并确保我们有 900 像素的宽度来工作。别忘了在 graph
div 中添加 class="span12"
参数。它告诉 Bootstrap 这个 div 应该占据网格的全宽。
为了避免触发浏览器关于跨域请求的安全限制,你现在应该花点时间下载数据集并将其保存到其他文件旁边。记住,它在 nightowls.swizec.com/data/histogram-hours.json
。
你可以在 Chrome 开发者工具中尝试以下代码,看看它做了什么,然后将其保存到 code.js
中。直接写入文件也行,但请确保经常刷新。学习就是当你知道每一行代码做什么的时候。
我们从以下一些变量开始:
var width = 900, height = 300, pad = 20, left_pad = 100;
我们将使用这些变量来指定绘图区域的尺寸。pad
变量将定义边缘的填充,left_pad
给左边更大的边距以允许标签。
接下来,我们定义一个水平比例尺,x
:
var x = d3.scale.ordinal().rangeRoundBands([left_pad, width - pad], 0.1);
x
比例尺现在是一个函数,它将来自一个尚未知的域(我们还没有数据)的输入映射到 left_pad
和 width - pad
之间的值域,即 100
和 880
之间,并有一些由 0.1
值定义的间隔。因为它是一个序数比例尺,域将必须是离散的而不是连续的。rangeRoundBands
意味着范围将被分成保证是圆形数字的带状区域。
然后,我们定义另一个名为 y
的比例尺:
var y = d3.scale.linear().range([height-pad, pad]);
类似地,y
轴将把一个尚未知的线性域映射到 height-pad
和 pad
之间的范围,即 880
和 20
。反转范围很重要,因为 d3.js 认为图表的顶部是 y=0
。
现在,我们按照以下方式定义我们的坐标轴:
var xAxis = d3.svg.axis().scale(x).orient("bottom");
var yAxis = d3.svg.axis().scale(y).orient("left");
我们已经告诉每个坐标轴在放置刻度时使用哪个比例尺,以及将标签放在坐标轴的哪一侧。D3 将自动决定显示多少刻度,它们的位置以及如何标记它们。
在加载数据之前的最后一步是定义直方图的 SVG 元素:
var svg = d3.select("#graph").append("svg")
.attr("width", width).attr("height", height);
快速切换到 元素 选项卡,你会注意到一个宽度为 900 像素、高度为 100 像素的新的 HTML 元素。
现在有趣的部分开始了!
我们将使用 d3.js 本身来远程加载数据,然后在回调函数中绘制图表。记住使用 Shift + Enter 在 Chrome 控制台中输入多行代码。现在可能是直接在 code.js
中编写代码并每走几步刷新的好时机:
d3.json('histogram-hours.json', function (data) {
});
d3.json
将创建一个 Ajax 请求来加载一个 JSON 文件,然后将接收到的文本解析成 JavaScript 对象。D3 还理解 CSV 和一些其他数据格式,如果你问我,这真的很棒。
从现在开始,我们将所有内容放入那个回调函数(在});
之前的部分)。我们的数据将存储在data
变量中。D3 是一个功能性的数据处理库,因此我们需要将我们的字典数据转换成一个简单的对象列表。我们使用以下代码来完成这个操作:
data = d3.keys(data).map(function (key) {
return {bucket: Number(key),
N: data[key]};
});
d3.keys
返回数据字典中的键列表,然后我们使用一个迭代函数map
遍历这些键,为每个项目返回一个简单的字典。它告诉我们一个项目在直方图(bucket
)中的位置以及它持有的值(N
)。
我们已经将数据转换成了一个包含两个值的字典列表。
记得之前的x
和y
轴吗?我们终于可以给它们一个域,使它们变得有用:
x.domain(data.map(function (d) { return d.bucket; }));
y.domain([0, d3.max(data, function (d) { return d.N; })]);
由于大多数 d3.js 元素既是对象又是函数,我们可以改变这两个轴的内部状态,而不需要将结果分配给任何东西。x
轴的域是一个离散值的列表。y
轴的域是从0
到我们数据集的d3.max
的范围——最大的值。
现在我们将在我们的图表上绘制坐标轴:
svg.append("g")
.attr("class", "axis")
.attr("transform", "translate(0, "+(height-pad)+")")
.call(xAxis);
我们已经将一个名为g
的元素添加到图中,给它分配了 CSS 类"axis"
,并使用transform
属性将其移动到图的左下角。
最后,我们调用xAxis
函数,让 d3.js 处理其余部分。
绘制其他轴的工作方式完全相同,但使用不同的参数:
svg.append("g")
.attr("class", "axis")
.attr("transform", "translate("+(left_pad-pad)+", 0)")
.call(yAxis);
现在我们已经为图表添加了标签,是时候绘制一些数据了:
svg.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', function (d) { return x(d.bucket); })
.attr('width', x.rangeBand())
.attr('y', function (d) { return y(d.N); })
.attr('height', function (d) { return height-pad - y(d.N); });
好吧,这里有很多事情在进行,但这段代码实际上非常简单:对于图中所有的矩形(rect
),加载我们的数据,遍历它,并为每个项目添加一个rect
,然后定义一些属性。
x
轴帮助我们计算水平位置,rangeBand
给出柱子的宽度。y
轴计算垂直位置,我们手动从y
到底部获取每个柱子的高度。请注意,每当我们需要为每个元素指定不同的值时,我们定义一个属性为函数(x
、y
和height
);否则,我们将其定义为值(width
)。
在调整时请记住这一点。
让我们添加一些装饰,让每个柱子从水平轴生长出来。是时候尝试动画了!
在前面的代码中添加五行:
svg.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr('class', 'bar')
.attr('x', function (d) { return x(d.bucket); })
.attr('width', x.rangeBand())
.attr('y', height-pad)
.transition()
.delay(function (d) { return d.bucket*20; })
.duration(800)
.attr('y', function (d) { return y(d.N); })
.attr('height', function (d) { return height-pad - y(d.N); });
不同之处在于我们静态地将所有柱子放置在底部(height-pad
),然后使用.transition()
进入一个过渡。从现在开始,我们定义我们想要的过渡。
首先,我们想要每个柱子的过渡延迟 20 毫秒,使用d.bucket*20
。这给直方图带来了一种整洁的效果,逐渐从左到右出现,而不是一次性跳起来。接下来,我们说我们想要每个动画持续不到一秒,使用.duration(800)
。最后,我们定义了动画属性的最终值——y
和height
与之前的代码相同——d3.js 将处理其余部分。
刷新页面,哇!一个漂亮的直方图就出现了,如下面的截图所示:
嗯,其实并不是这样。我们需要一些 CSS 来让一切看起来完美。
记住,如果你没有看到与前面的截图类似的内容,可以在 GitHub 上查看完整的代码github.com/Swizec/d3.js-book-examples/tree/master/ch1
。
让我们进入 HTML 文件,在包含bootstrap
之后在第 4 行添加一些 CSS:
<style>
.axis path,
.axis line {
fill: none;
stroke: #eee;
shape-rendering: crispEdges;
}
.axis text {
font-size: 11px;
}
.bar {
fill: steelblue;
}
</style>
正是因为这个原因,我们给形状添加了所有那些类。我们使坐标轴变细,给它们一种浅灰色,并为标签使用了相对较小的字体。条形应该是钢蓝色。现在刷新页面,直方图就变得很漂亮了:
我建议尝试调整width
、height
、left_pad
和pad
的值,以感受d3.js
的强大之处。你会发现一切都会根据任何大小进行缩放和调整,而无需更改其他代码。太棒了!
摘要
我们已经了解了什么是 d3.js,并对它的工作背后的核心哲学进行了初步了解。我们还为原型设计和可视化玩耍设置了一个快速简便的环境。这个环境将在整本书中被假设。
我们还通过一个简单的示例,使用d3.js
的一些基础知识创建了一个动画直方图。我们了解到关于比例和坐标轴的知识,垂直坐标轴是反转的,任何定义为函数的属性都会为每个数据点重新计算,以及我们使用 CSS 和 SVG 的组合来使事物变得美观。
最重要的是,这一章为你提供了基本工具,让你可以自己开始玩 d3.js。捣鼓是你的朋友。
第二章:DOM、SVG 和 CSS 入门
在本章中,我们将探讨使 d3.js 运行的核心技术:文档对象模型(DOM)、可缩放矢量图形(SVG)和层叠样式表(CSS)。
你可能已经习惯了使用 jQuery 或 MooTools 等库来操作 DOM 和 CSS,但 d3.js 也提供了一套完整的操作工具。
SVG 是构建真正优秀可视化图表的核心,因此我们将特别关注理解它;从手动绘制形状到变换和路径生成器的一切。
DOM
文档对象模型(Document Object Model)是一种语言无关的模型,用于表示在 HTML、XML 或类似标准中构建的结构化文档。你可以将其想象成一个节点树,它与浏览器解析的文档非常相似。
在顶部,有一个隐式的document
节点,它代表<html>
标签;即使你没有指定它,浏览器也会创建这个标签,然后根据你的文档外观从这个根节点构建树。如果你有一个简单的 HTML 文件如下所示:
<!DOCTYPE html>
<title>A title</title>
<div>
<p>A paragraph of text</p>
</div>
<ul>
<li>List item</li>
<li>List item 2, <em><strong>italic</strong></em></li>
</ul>
Chrome 将解析前面的代码为 DOM,如下所示:
在最新的 Chrome 版本中,我可以在控制台标签中打印和操作这些内容;你可能需要使用元素标签来获得相同的效果。将光标移到每个元素上,将显示它确切地放置在页面上的位置,这对于调试来说非常方便。
使用 d3.js 操作 DOM
DOM 树中的每个节点都附带了一系列你可以用来改变渲染文档外观的方法和属性。
以我们之前的例子中的 HTML 代码为例。如果我们想将单词italic
改为加粗并带有下划线(即<em>
和<strong>
标签的结果),我们会使用以下代码来完成:
document.getElementsByTagName('strong')[0].style.setProperty('text-decoration', 'underline')
哇!真是一大堆。
我们从根document
节点开始,找到了所有由<strong>
标签创建的节点;然后我们取这个数组中的第一个元素,并为其style
属性添加了一个text-decoration
属性。
在一个只有十一个节点的文档中完成这样简单的事情所需要的大量代码是为什么今天很少有人直接使用 DOM API 的原因——更不用说浏览器之间所有细微的差异了。
由于我们希望生活简单,避免直接使用 DOM,我们需要一个库。jQuery 是一个不错的选择,但为了使事情更加简单,我们可以使用 d3.js。它包含了我们所需的一切。
这意味着我们可以将 HTML 视为另一种类型的数据可视化。让我们好好消化这一点。HTML 就是数据可视化。
实际上,这意味着我们可以使用类似的技术将数据呈现为表格或交互式图像。最重要的是,我们可以使用相同的数据。
让我们用 d3.js 重写之前的例子:
d3.select('strong').style('text-decoration', 'underline')
简单多了!我们选择了strong
元素并定义了一个style
属性。任务完成!
顺便说一句,你可以使用 d3.js 设置的任何属性都是动态的,所以你可以分配一个函数以及一个值。这将在以后派上用场。
我们刚才所做的是称为 选择。由于选择是我们使用 d3.js 所做的一切的核心,让我们更仔细地看看。
选择
选择是一个根据特定 CSS 选择器从当前文档中提取的元素数组。选择器允许你一次性对整个选择集应用不同的函数,这样你就不必手动遍历元素。
使用 CSS 选择器来决定要处理的元素,为我们提供了一个定义文档中元素的简单语言。实际上,这与你从 jQuery 和 CSS 本身所熟悉的是一样的。
要获取 ID 为 graph
的第一个元素,我们使用 .select('#graph')
;要获取所有具有类 blue
的元素,我们写入 .selectAll('.blue')
;要获取文档中的所有段落,我们使用 .selectAll('p')
。
我们可以将这些组合起来以获得更复杂的匹配。想象成集合操作。你可以使用 ".this.that"
进行 AND 操作;这将获取具有 this
和 that
类的元素。或者,你可以使用 ".this, .that"
进行 OR 操作以获取具有 this
或 that
类的元素。
但如果你想要选择子元素呢?嵌套选择可以解决这个问题。你可以使用一个简单的选择器,例如 "tbody td"
,或者你可以链式调用两个 selectAll
调用,如 .selectAll('tbody').selectAll('td')
。两者都将选择表格体中的所有单元格。请注意,嵌套选择保持所选元素之间的层次结构,这为我们提供了一些有趣的能力。让我们看看一个简短的例子。
选择示例
从第一章中的实验环境获取基本 HTML 并添加一个简单的表格:
<table class="table">
<thead>
<tr><td>One</td><td>Two</td><td>Three</td><td>Four</td><td>Five</td></tr>
</thead>
<tbody>
<tr><td>q</td><td>w</td><td>e</td><td>r</td><td>t</td></tr>
<tr><td>a</td><td>s</td><td>d</td><td>f</td><td>g</td></tr>
<tr><td>z</td><td>x</td><td>c</td><td>v</td><td>b</td></tr>
</tbody>
</table>
几乎是表格的标准标记,<thead>
和 <tbody>
定义了表格的头部和主体,其中每个 <tr>
是一行,每个 <td>
是一个单元格。添加 table
类告诉 Bootstrap 为我们使表格看起来更美观。
让我们进入控制台,用选择做一些有趣的事情:
d3.selectAll('td').style('color', 'red')
文本将立即变为红色。现在让我们通过链式调用两个 selectAll
来使表格标题中的所有内容加粗:
d3.selectAll('thead').selectAll('td').style('font-weight', 'bold')
太棒了!让我们进一步探讨嵌套选择,并将表格体的第二列和第四列单元格变为绿色:
d3.selectAll('tbody tr').selectAll('td')
.style('color', function (d, i) { return i%2 ? 'green' : 'red'; })
两个 selectAll
调用为我们提供了体中所有 td
的实例,按行分隔,给我们一个包含三个数组的数组,每个数组有五个元素:[ Array[5], Array[5], Array[5] ]
。然后我们使用 style
改变所选元素的颜色。
使用函数而不是静态属性给了我们所需的精细控制。该函数使用数据属性(我们稍后会讨论更多)和列的索引调用,即 i
变量。由于我们使用嵌套选择,第三个参数将给我们行。然后我们简单地根据当前索引返回 'green'
或 'red'
。
需要注意的一点是,当涉及到非常大的文档时,链式调用选择可能比使用 OR 选择器更高效。这是因为每个后续选择只搜索之前匹配的元素。
操作内容
我们可以做的不仅仅是围绕选择和更改元素属性打转。我们可以操纵事物。
使用 d3.js,我们可以更改元素的内容,添加新元素,或删除我们不需要的元素。
让我们给之前示例中的表格添加一个新列:
var newCol = d3.selectAll('tr').append('td')
我们选择了所有的表格行,然后使用 .append()
为每一行添加了一个新单元格。所有 d3.js 操作都会返回当前选择——在这个例子中是新单元格——因此我们可以链式调用操作或将新选择赋值给变量(newCol
)以供以后使用。
我们手里有一个空的无形列。让我们添加一些文本来让它看起来更美观:
newCol.text('a')
至少现在它充满了 a
实例,我们可以说列是存在的。但这有点没有意义,所以让我们遵循其他列设定的模式:
newCol.text(function (d, i) { return ['Six', 'y', 'h', 'n'][i] })
通过函数动态定义内容的技巧帮助我们根据我们所在的列从值列表中选择正确的字符串,我们通过索引 i
来识别列。
你已经找到了这个模式了吗?阅读表体的顶部行。
同样,我们可以使用 .remove()
来删除元素。要删除表格中的最后一行,你会写如下内容:
d3.selectAll('tr')[0][3].remove()
你必须使用 [0][3]
而不是 [3]
,因为选择是数组的数组。
将数据连接到选择
我们已经到达了我们 DOM 恶作剧的有趣部分。记得我曾经说过 HTML 是数据可视化吗?将数据连接到选择就是如何实现这一点的。
要将数据与选择连接起来,我们使用 .data()
函数。它接受一个数据参数,形式为一个函数或一个数组,并且可选地提供一个函数告诉 d3.js 如何区分数据的不同部分。
当你将数据连接到选择时,以下三种情况之一将会发生:
-
已连接的数据比之前更多(数据的长度超过了选择的长度)。你可以使用
.enter()
函数来引用新的条目。 -
数据量与之前完全相同。你可以使用
.data()
返回的选择来更新元素状态。 -
数据量比之前少。你可以使用
.exit()
函数来引用这些数据。
你不能链式调用 .enter()
和 .exit()
,因为它们只是引用,并不会创建一个新的选择。这意味着你通常想要专注于 .enter()
和 .exit()
,并分别处理三种情况。请注意,这三种情况可能同时发生。
您可能想知道,“但为什么数据比之前多或少了?”这是因为选择元素绑定到数据实例,而不是它们的数量。如果您移动了一个数组并添加了一个新值,那么前面的第一个项目将进入.exit()
引用,而新的添加将进入.enter()
引用。
让我们用数据连接和 HTML 构建一些有趣的东西。
一个 HTML 可视化示例
我们将开始使用与往常一样的 HTML 文件。我建议从现在开始编码code.js
文件,因为事情可能会变得相当复杂。经常刷新以保持对正在发生的事情的关注。
每个伟大的可视化都需要一个数据集;我们将使用自 1963 年以来所有《神秘博士》怪物和坏蛋的列表。它是由《卫报数据博客》在 2012 年 12 月底发布的。您可以从github.com/Swizec/d3.js-book-examples/blob/master/ch2/villains.csv
获取 CSV 文件。
我们将创建一个表格。当然,这并不令人兴奋,但对于 HTML 可视化来说非常实用。
我们从一个全局数据变量开始。
在您的code.js
文件顶部添加以下行:
var Data;
然后我们使用以下代码将一个空表附加到我们的graph
div 中:
var table = d3.select('#graph')
.append('table')
.attr('class', 'table');
var thead = table.append('thead'),
tbody = table.append('tbody');
如您从前面的示例中可以想象到的,这段代码通过 ID 选择目标<div>
标签graph
,并附加一个具有class='table'
属性的table
元素,这样 Bootstrap 就会使其看起来很吸引人。
接下来的两行将空的thead
和tbody
元素附加到变量中,以供以后使用。
现在我们将加载数据并将其分配给Data
变量:
var reload = function () {
d3.csv('villains.csv', function (data) {
Data = data;
redraw();
});
};
reload();
我们稍后会修改数据集,所以有一个函数在需要重新加载数据而不需要刷新页面时可以调用是很方便的。
由于我们的数据集是 CSV 格式,我们使用 d3.js 的csv
函数来加载和解析它。d3.js 足够聪明,能够理解我们的数据集中的第一行不是数据,而是一组标签,因此它以以下方式将字典数组填充到data
变量中:
{
"Villain": "Abzorbaloff (Victor Kennedy)",
"Year first": "2006",
"Year last": "2006",
"Doc. no.": "10",
"Doctor actor": "David Tennant",
"Epi- sodes": "1",
"Stories, total": "1",
"Motivation (invasion earth, end of universe, etc)": "Kill humans",
"Story titles": "Love and Monsters"
}
如果您现在运行代码,Chrome 会抱怨redraw()
函数不存在。让我们按照以下方式编写一个:
var redraw = function () {
};
我们定义了一个redraw
变量,并将其分配给一个空函数。
我们下一步是让这个函数做些事情。让我们进入它的主体(在两个大括号之间)并添加一些代码:
var tr = tbody.selectAll('tr')
.data(Data);
tr.enter()
.append('tr');
tr.exit()
.remove();
代码分为三个部分。第一部分选择所有表格行(目前还没有)并使用.data()
函数将我们的Data
连接起来。结果选择被保存在tr
变量中。
接下来我们使用.enter()
引用为数据集中的每个新数据创建一个表格行。现在,这是针对所有数据。
代码的最后一部分目前还没有做任何事情,但一旦我们稍后更改数据,它将删除.exit()
引用中的任何<tr>
元素。
执行后,tr
变量将包含一个<tr>
元素的数组,每个元素都绑定到数据集中的相应位置。第一个<tr>
元素包含第一个数据,第二个包含第二个数据,依此类推。
没有单元格的行是没有用的。让我们通过依赖于数据在新的选择之后仍然与元素相连的事实来添加一些单元格:
tr.selectAll('td')
.data(function (d) { return d3.values(d); })
.enter()
.append('td')
.text(function (d) { return d; });
我们选择了每一行的所有<td>
子元素(目前还没有)。然后我们必须使用d3.values()
将相同的数据转换成一个值列表,并调用.data()
函数。这给了我们一个使用.enter()
的新机会。
从那时起,情况就更加相似。每个新的条目都会得到自己的表格单元格,文本设置为当前数据。
运行这段代码将给你一个完全令人困惑的表格,列出了自 1963 年以来在电视上出现的所有《博士》怪物和反派。
让我们让它更清晰。如果你想看到一些实时更新的魔法,你可以在redraw()
函数的底部编写这段代码,或者直接在 Chrome 的 JavaScript 控制台中编写。
要按反派首次出现对表格进行排序,我们编写以下代码:
tbody.selectAll('tr')
.sort(function (a, b) { return d3.ascending(a['Year first'], b['Year first']); });
不做任何其他事情,这段代码将重新绘制表格并按新的顺序排列——无需刷新页面,无需手动添加或删除元素。因为所有我们的数据都与 HTML 相连,所以我们甚至不需要引用原始的tr
选择或数据。如果你问我,这非常巧妙。
.sort()
函数只接受一个比较函数。比较函数会给出两份数据,并必须决定如何排序:-1
表示小于b
,0
表示等于,1
表示大于b
。你还可以使用 d3.js 的d3.ascending
和d3.descending
比较器。
虽然仍然不太清楚,但让我们只限制我们的表格到最新的博士:
Data = Data.filter(function (d) { return d['Doctor actor'] == 'Matt Smith'; })
redraw()
我们过滤了数据集,使其只包含演员是马特·史密斯(Matt Smith)的行,然后调用了redraw()
函数。.exit()
选择完成了它的任务,并从表格中删除了几百行。等等……我们最终得到了一个演员的大杂烩。花了我一段时间才弄清楚发生了什么。
JavaScript 是一种基于实例的标识语言,这意味着 d3.js 不能使用a == b
来判断两个复杂对象是否相同。相反,它依赖于索引来识别对象。所以当我们过滤数据时,前x个索引中有内容,被认为是未更改的,其余的则被删除。已经附加到元素上的数据不会更新,我们手上就有一个糟糕的表格。我们可以通过两种方式摆脱这种情况。
我们可以先对表格进行排序,然后按如下方式过滤数据:
tbody.selectAll('tr').sort(function (a, b) {
return d3.descending(Number(a['Doc. no.']), Number(b['Doc. no.']));
});
与之前一样,我们使用比较器进行排序;我们使用a['Doc. no.']
和b['Doc. no.']
之间的数值比较来按降序排序行,最高的数字在最上面。
运行与之前相同的代码将得到期望的结果:
Data = Data.filter(function (d) { return d['Doctor actor'] == 'Matt Smith'; })
redraw()
这之所以有效,是因为 Matt Smith 在数据集的前几个位置。但这种方法只适用于这个例子。我们可以使用更健壮的方法,但这不会在更改数据时自动发生。请记住刷新页面或运行reload()
以获取整个数据集。
现在我们可以直接过滤表格,如下所示:
tbody.selectAll('tr')
.filter(function (d) { return d['Doctor actor'] != 'Matt Smith'; })
.remove()
.filter()
函数接受一个选择器作为其参数,并将当前数据传递给它。当函数返回false
时,元素将从选择中移除;当它返回true
时,元素将保留。最后,我们使用.remove()
函数移除我们捕获的所有行。这更加健壮,但直接操作数据本身通常更加优雅。请明智地选择。
SVG
可缩放矢量图形(Scalable Vector Graphics)是一种使用 XML 描述图像的矢量图形格式。它自 1999 年以来一直存在,并且现在所有主流浏览器都支持它。不幸的是,Internet Explorer 一直落后,从版本 9 开始只提供了有限的支持。矢量图像可以以任何大小渲染而不会变得模糊。这意味着你可以在大型的视网膜显示屏或小型的手机上渲染相同的图像,两种情况下都会看起来很棒。
SVG 图像是由形状组成的,你可以使用路径从头开始创建这些形状,或者从标准中定义的基本形状组合而成,例如一条线或一个圆。该格式本身使用 XML 元素和一些属性来表示形状。
因此,SVG 代码只是一堆你可以手动编辑、使用浏览器正常的调试工具检查,以及使用标准文本压缩算法压缩的文本。基于文本也意味着你可以使用 d3.js 在你的浏览器中创建一个图像,然后将生成的 XML 复制并粘贴到.svg
文件中,并用任何 SVG 查看器打开它。
另一个后果是,浏览器可以将 SVG 视为文档的正常部分。你可以使用 CSS 进行样式设计,监听特定形状上的鼠标事件,甚至可以编写脚本使图像具有交互性。
使用 SVG 绘制
使用 d3.js 绘制图形时,你可以通过定义适当的 SVG 元素手动添加形状,或者你可以使用辅助函数来帮助你轻松创建高级形状。
现在我们将深入探讨 d3.js 的核心功能。所有其他功能都是基于这个核心构建的,所以请务必注意。
让我们从在我们的常规环境中准备一个绘图区域开始。将以下代码放在一个新的code.js
文件顶部:
var svg = d3.select('#graph')
.append('svg')
.style('width', 1024)
.style('height', 768);
我们将一个<svg>
元素附加到主<div>
标签上,并调整了其大小。从现在开始,我们将使用svg
变量进行绘图。
手动添加元素和形状
SVG 图像是由形状组成的元素集合,它包含一组七个基本元素。除了一个之外,这些元素只是定义路径的一种更简单的方式:
-
文本(唯一一个不是路径的)
-
直线
-
矩形
-
圆形
-
椭圆
-
多段线(一组直线)
-
多边形(一组直线,闭合于自身)
您可以通过将这些元素添加到画布中并定义一些属性来构建 SVG 图像。所有这些元素都可以有一个 stroke
样式来定义边缘的渲染方式,一个 fill
样式来定义形状的填充方式,并且所有这些都可以使用 transform
属性进行旋转、倾斜或移动。
文本
文本是唯一一个既不是形状也不像其他元素那样在背景中转换为路径的元素。让我们先看看它,这样本章的其余部分就可以关于形状了:
svg.append('text')
.text("A picture!")
.attr({x: 10,
y: 150,
'text-anchor': 'start'});
我们取了我们的 svg
元素并添加了一个 text
元素。然后我们定义了其实际文本,并添加了一些属性来定位文本在 (x, y)
点,并将文本锚定在开始位置。
text-anchor
属性定义了渲染文本相对于由 (x, y)
定义的锚点的水平位置。它理解的位置是开始、中间和结束。
我们还可以使用 dx
和 dy
属性定义的偏移量来微调文本的位置。这在调整文本边距和基线相对于字体大小时特别有用,因为它理解 em
单位。
我们的形象如下所示:
形状
现在文本已经处理完毕,让我们看看一些有用的东西——形状,这是本书其余部分的核心。
我们首先使用以下代码绘制一条直线:
svg.append('line')
.attr({x1: 10,
y1: 10,
x2: 100,
y2: 100,
stroke: 'blue',
'stroke-width': 3});
就像之前一样,我们取了 svg
元素,添加了一条线,并定义了一些属性。一条线在两个点之间绘制:(x1, y1)
和 (x2, y2)
。为了使线条可见,我们必须定义线条颜色和 stroke-width
属性。
尽管y2
大于y1
,我们的线点却向下。嗯……这是因为大多数图像格式的原点位于左上角。这意味着 (x=0, y=0)
定义了图像的左上角。
要绘制一个矩形,我们可以使用 rect
元素:
svg.append('rect')
.attr({x: 200,
y: 50,
width: 300,
height: 400});
我们将一个 rect
元素添加到 svg
元素中,并定义了一些属性。一个矩形由其左上角 (x
,y
)、width
和 height
定义。
我们的形象现在如下所示:
我们有一个难以驾驭的黑色矩形。我们可以通过定义以下三个更多属性来让它更漂亮:
svg.select('rect')
.attr({stroke: 'green',
'stroke-width': 0.5,
fill: 'white',
rx: 20,
ry: 40});
这好多了。我们的矩形有一个细的、绿色的轮廓。圆角来自 rx
和 ry
属性,它们定义了沿 x 和 y 轴的角落半径:
让我们尝试添加一个圆:
svg.append('circle')
.attr({cx: 350,
cy: 250,
r: 100,
fill: 'green',
fill-opacity': 0.5,
stroke: 'steelblue',
'stroke-width': 2});
一个圆由一个中心点 (cx, cy)
和一个半径 r
定义。在这个例子中,我们在矩形的中间得到了一个 green
圆圈,有一个 steelblue
轮廓。fill-opacity
属性告诉圆圈稍微透明,这样它就不会在浅色矩形上显得太强烈:
从数学的角度讲,一个圆只是一个特殊的椭圆形式。通过添加另一个半径并更改元素,我们可以绘制以下形状之一:
svg.append('ellipse')
.attr({cx: 350,
cy: 250,
rx: 150,
ry: 70,
fill: 'green',
'fill-opacity': 0.3,
stroke: 'steelblue',
'stroke-width': 0.7});
我们添加了一个 ellipse
元素并定义了一些已知的属性。椭圆形状需要一个中心点 (cx, cy)
和两个半径,rx
和 ry
。设置一个低的 fill-opacity
属性使得圆形在椭圆下可见:
这不错,但我们可以用以下代码让它更有趣:
svg.append('ellipse')
.attr({cx: 350,
cy: 250,
rx: 20,
ry: 70});
这里的唯一技巧是 rx
小于 ry
,创建了一个垂直的椭圆。太棒了!
一个奇怪的绿色眼睛和一条随机的蓝色线条在盯着你,这一切都归功于手动将基本 SVG 元素添加到画布上并定义了一些属性。
生成的 SVG 以 XML 形式如下所示:
<svg style="width: 1024px; height: 768px;">
<text x="10" y="150" text-anchor="start">A picture!</text>
<line x1="10" y1="10" x2="100" y2="100" stroke="blue" stroke-width="3"></line>
<rect x="200" y="50" width="300" height="400" stroke="green" stroke-width="0.5" fill="white" rx="20" ry="40"></rect>
<circle cx="350" cy="250" r="100" fill="green" fill-opacity="0.5" stroke="steelblue" stroke-width="2"></circle>
<ellipse cx="350" cy="250" rx="150" ry="70" fill="green" fill-opacity="0.3" stroke="steelblue" stroke-width="0.7"></ellipse>
<ellipse cx="350" cy="250" rx="20" ry="70"></ellipse>
</svg>
是的,我也不想手动写那个。
但你可以看到我们之前添加的所有元素和属性。能够查看图像文件并理解其中发生的事情可能在某一天很有用。这确实很酷。通常当你用文本编辑器打开图像时,你得到的就是二进制的乱码。
现在,我知道我之前提到过多边形和多边形也是基本的 SVG 元素。我之所以省略这些基本元素的解释,是因为在使用 d3.js 时,我们有很好的工具来处理它们。相信我,你不想手动做这些。
变换
在深入研究更复杂的事情之前,我们必须看看变换。
不深入数学细节,只需说,SVG 中使用的变换是我们绘图形状所使用的坐标系中的仿射变换。美妙的是,它们可以定义为矩阵乘法,这使得它们在计算上非常高效。
但是,除非你的大脑是由线性代数构成的,否则使用矩阵作为变换可能会变得非常复杂。SVG 通过提供一系列预定义的变换来帮助,即 translate()
、scale()
、rotate()
、skewX()
和 skewY()
。
根据维基百科,仿射变换是任何保持点、直线和平面,同时保持平行线集合平行的变换。它们不一定保持距离,但确实保持直线上的点之间距离的比率。这意味着如果你取一个矩形,你可以使用仿射变换来旋转它,放大它,甚至将其变成平行四边形;然而,无论你做什么,它永远不会变成梯形。
计算机将变换处理为矩阵乘法,因为任何变换序列都可以折叠成一个单一的矩阵。这意味着它们在绘制形状时只需要应用一个包含你的变换序列的单个变换,这很方便。
我们将使用 transform
属性应用变换。我们可以定义多个按顺序应用的变换。操作顺序可以改变结果。你将在以下示例中注意到这一点。
让我们把目光移到矩形的边缘:
svg.selectAll('ellipse, circle')
.attr('transform', 'translate(150, 0)');
我们选择了眼睛所构成的一切(两个椭圆和一个圆),然后应用了 translate
变换。它将形状的起点沿着 (150, 0)
向量移动,将形状向右移动 150 像素,向下移动 0 像素。
如果你再次尝试移动它,你会注意到新的变换是按照我们形状的原始状态应用的。这是因为每个形状只能有一个 transform
属性。
我们的图片看起来如下:
让我们旋转眼睛 45 度:
svg.selectAll('ellipse, circle')
.attr('transform', 'translate(150, 0) rotate(45)');
这根本就不是我们想要的。
欺骗我们的地方在于旋转是围绕图像的起点进行的,而不是形状。我们必须自己定义旋转轴:
svg.selectAll('ellipse, circle')
.attr('transform', 'translate(150, 0) rotate(-45, 350, 250)');
通过向 rotate()
函数添加两个额外的参数,我们定义了旋转轴并达到了预期的效果:
让我们使用 scale()
变换使眼睛稍微大一点:
svg.selectAll('ellipse, circle')
.attr('transform', 'translate(150, 0) rotate(-45, 350, 250) scale(1.2)');
这将使我们的对象在两个轴向上都扩大到 1.2
倍;两个参数会以不同的因子沿 x 轴和 y 轴缩放:
再次,我们调整了眼睛的位置,因为缩放是以整个图像的零点为基准的。我们必须使用另一个 translate
来将其移回。但现在我们正在工作的坐标系已经旋转了 45 度并进行了缩放。这使得事情变得复杂。我们需要在这两个坐标系之间进行转换,才能正确地移动眼睛。为了将眼睛向左移动 70 像素,我们必须沿着每个轴移动 70sqrt(2)/2* 像素,这是 45 度角的余弦和正弦的结果。
但这只是混乱。数字看起来很奇怪,我们为了这么简单的事情做了太多的工作。让我们改变操作顺序:
svg.selectAll('ellipse, circle')
.attr('transform', 'translate(150, 0) scale(1.2) translate(-70, 0) rotate(-45, '+(350/1.2)+', '+(250/1.2)+')');
好多了!我们得到了我们想要的确切效果:
发生了很大的变化,让我们看看。
首先,我们将它转换到我们熟悉的位置,然后通过 1.2
缩放,将眼睛推离位置。我们通过向左移动 70
像素来修复这个问题,然后最终执行 45
度旋转,确保将旋转中心点除以 1.2
。
我们还可以对这只可怜的眼睛做一件事;让它倾斜。存在两种倾斜变换:skewX
和 skewY
。两者都沿着各自的轴倾斜:
svg.selectAll('ellipse, circle')
.attr('transform', 'translate(150, 0) scale(1.2) translate(-70, 0) rotate(-45, '+(350/1.2)+', '+(250/1.2)+') skewY(20)');
我们只是将 skewY(20)
添加到 transform
属性的末尾。
我们又一次破坏了我们精心对齐的中心。修复这个问题留给读者作为练习(我一直想这么说)。
总的来说,变换实际上只是矩阵乘法。实际上,你可以使用 matrix()
函数定义你想要的任何变换。我建议查看确切哪种矩阵会产生前面提到的效果。W3C 规范可在www.w3.org/TR/SVG/coords.html#EstablishingANewUserSpace
找到。
使用路径
路径元素定义了可以填充、描边等形状的轮廓。它们是所有其他形状的泛化,可以用来绘制几乎任何东西。
大多数路径的魔法都源于 d
属性;它使用三种基本命令的迷你语言:
-
M
,表示移动到 -
L
,表示线到 -
Z
,表示闭合路径
要创建一个矩形,我们可能会写如下内容:
svg.append('path')
.attr({d: 'M 100 100 L 300 100 L 200 300 z',
stroke: 'black',
'stroke-width': 2,
fill: 'red',
'fill-opacity': 0.7});
我们向我们的 svg
添加了一个新元素,并定义了一些属性。有趣的部分是 d
属性,M 100 100 L 300 100 L 200 300 z
。分解来看,我们首先移动到 (100
, 100
),在 (300
, 100
) 上画了一条线,然后在 (200
, 300
) 上又画了一条线,最后闭合路径。
路径的力量并不仅限于此。M
、L
、Z
组合之外的命令给我们提供了创建曲线和圆弧的工具。但手动创建复杂形状已经超出了繁琐。
d3.js 附带一些有用的路径生成器函数,它们可以将 JavaScript 转换为路径定义。我们将在下一章中查看这些函数。
我们的形象变得越来越拥挤,所以让我们重新启动环境。
首先,我们将绘制一个谦逊的正弦函数。再一次,我们首先准备绘图区域:
var width = 1024,
height = 768,
margin = 10;
var svg = d3.select('#graph')
.append('svg')
.attr('width', width+2*margin)
.attr('height', height+2*margin);
var g = svg.append('g')
.attr('transform', 'translate('+margin+', '+margin+')');
我们向我们的 #graph
div 添加了一个 svg
元素,并设置了足够大的 width
和 height
以满足我们的邪恶计划。然后,我们添加了一个 g
元素来放置我们的图表。g
元素是 SVG 形状的逻辑分组,提高了我们文档的语义性,并使其更容易使用。
接下来,我们需要一些数据,即正弦函数。
var sine = d3.range(0,10).map(
function (k) { return [0.5*k*Math.PI,
Math.sin(0.5*k*Math.PI)]; });
使用 d3.range(0,10)
会给我们一个从零到九的整数列表。我们遍历它们,并将每个转换为元组,实际上是一个表示曲线的最大值、最小值和零点的 2 长度数组。你可能还记得从你的数学课上学到的,正弦函数从 (0,0) 开始,然后到 (π/2, 1),(π, 0),(3π/2, -1),以此类推。
我们将这些作为数据输入到路径生成器中。
路径生成器实际上是 d3.js 魔法的精髓。我们将在 第五章 中讨论魔法的精华,布局 – d3 的黑魔法。它们本质上是一个函数,它接受一些数据(与元素相关联)并生成 SVG 路径迷你语言中的路径定义。所有路径生成器都可以被告知如何使用我们的数据。我们还可以大量地玩转最终输出。
线
要创建一条线,我们使用 d3.svg.line()
生成器并定义 x 和 y 访问器函数。访问器告诉生成器如何从数据点读取 x 和 y 坐标。
我们首先定义两个比例尺。比例尺是从域到范围的函数;我们将在下一章中更多地讨论它们:
var x = d3.scale.linear()
.range([0, width/2-margin])
.domain(d3.extent(sine, function (d) { return d[0]; })),
y = d3.scale.linear().range([height/2-margin, 0]).domain([-1, 1]);
现在我们需要定义一个简单的路径生成器:
var line = d3.svg.line()
.x(function (d) { return x(d[0]); })
.y(function (d) { return y(d[1]); });
这只是将基本线生成器与一些访问器附加在一起的问题。我们告诉生成器使用我们的 x
尺度在元组的第一个元素上,并在第二个元素上使用 y
尺度。默认情况下,它假设我们的数据集是一个直接定义点的数组的集合,因此 d[0]
是 x
,d[1]
是 y
。
现在只剩下绘制实际的线:
g.append('path')
.datum(sine)
.attr("d", line)
.attr({stroke: 'steelblue',
'stroke-width': 2,
fill: 'none'});
添加一个路径,并使用 .datum()
添加 sine
数据。使用这种方法而不是 .data()
意味着我们可以将函数渲染为单个元素,而不是为每个点创建新的一行。我们让生成器定义 d
属性。其余的只是使事物可见。
我们的图表如下所示:
如果您查看生成的代码,您会看到这种乱七八糟的东西:
d="M0,192L56.88888888888889,0L113.77777777777779,191.99999999999994L170.66666666666669,384L227.55555555555557,192.00000000000006L284.44444444444446,0L341.33333333333337,191.99999999999991L398.2222222222223,384L455.11111111111114,192.00000000000009L512,0"
看吧!我告诉过你没有人愿意手动编写这些。
那是一个非常锯齿状的 sine
函数,与高中数学老师画的不一样。我们可以通过插值来改进它。
插值是猜测线条上未指定点应出现的位置的行为,考虑到我们已知的点。默认情况下,我们使用 linear
插值器,它只是在点之间绘制直线。
让我们尝试其他的东西:
g.append('path')
.datum(sine)
.attr("d", line.interpolate('step-before'))
.attr({stroke: 'black',
'stroke-width': 1,
fill: 'none'});
这与之前的代码相同,但我们使用了 step-before
插值器,并更改了样式以生成以下内容:
d3.js 总共提供了 12 个线插值器,这里不再一一列举。您可以在官方维基页面上查找它们:github.com/mbostock/d3/wiki/SVG-Shapes#wiki-line_interpolate
。
我建议尝试所有这些,以了解它们的作用。
区域
区域是两条线之间的彩色部分,实际上是一个多边形。
我们定义一个区域,类似于定义线的方式,所以取一个路径生成器并告诉它如何使用我们的数据。对于简单的水平区域,我们必须定义一个 x 访问器和两个 y 访问器,y0
和 y1
,用于底部和顶部。
我们将并排比较不同的生成器,所以让我们添加一个新的图表:
var g2 = svg.append('g')
.attr('transform', 'translate('+(width/2+margin)+', '+margin+')');
现在我们定义一个 area
生成器并绘制一个区域。
var area = d3.svg.area()
.x(function (d) { return x(d[0]); })
.y0(height/2)
.y1(function (d) { return y(d[1]); })
.interpolate('basis');
g2.append('path')
.datum(sine)
.attr("d", area)
.attr({fill: 'steelblue',
'fill-opacity': 0.4});
我们取了一个普通的 d3.svg.area()
路径生成器,并告诉它通过我们之前定义的 x
和 y
尺度获取坐标。basis
插值器将使用 B 样条从我们的数据创建平滑曲线。
为了绘制底部边缘,我们将 y0
定义为图表的底部,并生成一个彩色的正弦近似:
区域通常与使重要边缘突出的线一起使用。让我们试试这个:
g2.append('path')
.datum(sine)
.attr("d", line.interpolate('basis'))
.attr({stroke: 'steelblue',
'stroke-width': 2,
fill: 'none'});
我们可以重用之前的线生成器;我们只需要确保使用与区域相同的插值器。这样,图像看起来会好得多:
弧
弧是一个有内半径和外半径的圆形路径,从一个角度到另一个角度。它们通常用于饼图和甜甜圈图。
一切都和以前一样工作;我们只需告诉基础生成器如何使用我们的数据。唯一的不同是这次默认的访问器期望的是命名属性,而不是我们习惯的 2 值数组。
让我们画一个弧:
var arc = d3.svg.arc();
var g3 = svg.append('g')
.attr('transform', 'translate('+margin+', '+(height/2+margin)+')');
g3.append('path')
.attr("d", arc({outerRadius: 100,
innerRadius: 50,
startAngle: -Math.PI*0.25,
endAngle: Math.PI*0.25}))
.attr('transform', 'translate(150, 150)')
.attr('fill', 'lightslategrey');
这次我们可以使用默认的 d3.svg.arc()
生成器。我们不是使用数据,而是手动计算角度,并将弧推向中心。
看看,一个简单的弧。庆祝吧!
尽管 SVG 通常使用度数,但起始和结束角度使用弧度。零角度向上,负值逆时针移动,正值朝相反方向移动。每 2Pi
我们回到零。
符号
有时候在可视化数据时,我们需要一种简单的方式来标记数据点。这就是符号的作用,它们是用于区分数据点的微小符号。
d3.svg.symbol()
生成器接受一个 type
访问器和 size
访问器,并将定位留给我们。我们将在我们的面积图中添加一些符号,以显示函数穿过零时函数的走向。
和往常一样,我们从路径生成器开始:
var symbols = d3.svg.symbol()
.type(function (d, i) {
if (d[1] > 0) {
return 'triangle-down';
}else{
return 'triangle-up';
}
})
.size(function (d, i) {
if (i%2) {
return 0;
}else{
return 64;
}
});
我们给 d3.svg.symbol()
生成器提供了一个 type
访问器,告诉它在 y 坐标为正时绘制向下指的三角形,在非正时绘制向上指的三角形。这之所以有效,是因为我们的 sine
数据由于 Math.PI
不是无限的以及由于浮点精度,数学上并不完美;我们得到接近零的无限小数,其符号取决于 Math.sin
参数是略小于还是略大于 sin=0
的完美点。
size
访问器告诉 symbol()
每个符号应该占用多少面积。因为其他每个数据点都接近零,所以我们用面积为零的方式隐藏了它们。
现在我们可以绘制一些符号:
g2.selectAll('path')
.data(sine)
.enter()
.append('path')
.attr('d', symbols)
.attr('transform', function (d) { return 'translate('+x(d[0])+','+y(d[1])+')'; })
.attr({stroke: 'steelblue',
'stroke-width': 2,
fill: 'white'});
遍历数据,为每个条目添加一个新的路径,并将其转换为一个移动到位置的符号。结果如下所示:
你可以通过打印 d3.svg.symbolTypes
来查看其他可用的符号。
弦
好消息!我们正在离开简单图表的世界,进入魔法的世界。
弦通常用于在圆形排列中显示组元素之间的关系。它们使用二次贝塞尔曲线创建一个封闭形状,连接圆弧上的两个点。
如果你没有强大的计算机图形背景,这对你来说毫无意义。基本弦看起来像半个坏蛋的胡须:
要绘制这个,我们使用以下代码片段:
g3.append('g').selectAll('path')
.data([{
source: {radius: 50,
startAngle: -Math.PI*0.30,
endAngle: -Math.PI*0.20},
target: {radius: 50,
startAngle: Math.PI*0.30,
endAngle: Math.PI*0.30}}])
.enter()
.append('path')
.attr("d", d3.svg.chord());
这段代码添加了一个新的分组元素,定义了一个包含单个数据的数据集,并使用默认的 d3.svg.chord()
生成器为 d
属性附加了一个路径。
数据本身正好符合默认访问器的处理方式。Source
定义了和弦的起始位置,target
定义了和弦的结束位置。这两个都输入到另一组访问器中,指定弧的radius
、startAngle
和endAngle
。与弧生成器一样,角度使用弧度定义。
让我们编造一些数据并绘制一个弦图:
var data = d3.zip(d3.range(0, 12),
d3.shuffle(d3.range(0, 12))),
colors = ['linen', 'lightsteelblue', 'lightcyan',
'lavender', 'honeydew', 'gainsboro'];
没有什么太花哨的。我们定义了两个数字数组,其中一个被随机打乱,然后合并成一个成对的数组;我们将在下一章中查看细节。然后我们定义了一些颜色。
var chord = d3.svg.chord()
.source(function (d) { return d[0]; })
.target(function (d) { return d[1]; })
.radius(150)
.startAngle(function (d) { return -2*Math.PI*(1/data.length)*d; })
.endAngle(function (d) {
return -2*Math.PI*(1/data.length)*((d-1)%data.length); });
所有这些只是定义了生成器。我们将把圆分成几个部分,并用弦连接随机配对的点。
.source()
和.target()
访问器告诉我们每一对中的第一个元素是源,第二个是目标。对于startAngle
,我们记得一个完整的圆是2Pi,然后除以部分的数量。最后,为了选择一个部分,我们乘以当前的值。endAngle
访问器与之前类似,只是数据偏移了一个。
g3.append('g')
.attr('transform', 'translate(300, 200)')
.selectAll('path')
.data(data)
.enter()
.append('path')
.attr('d', chord)
.attr('fill', function (d, i) { return colors[i%colors.length]; })
.attr('stroke', function (d, i) { return colors[(i+1)%colors.length]; });
要绘制实际的图表,我们创建一个新的分组,连接数据集,然后为每个数据项附加一个路径。chord
生成器给它一个形状。为了使一切看起来更好,我们使用colors
数组动态定义颜色。
最终结果每次刷新都会改变,但看起来可能像这样:
对角线
diagonal
生成器创建三次贝塞尔曲线——两点之间的平滑曲线。这对于用节点-链接图可视化树非常有用。
再次强调,默认访问器假设你的数据是一个字典,键名与特定的访问器同名。你需要source
和target
,它们被输入到projection
中,然后它将笛卡尔坐标投影到你喜欢的任何坐标空间中。默认情况下,它只返回笛卡尔坐标。
让我们画一个胡子。没有d3.layouts
,树状图就很难画,我们稍后再做这些:
var g4 = svg.append('g')
.attr('transform', 'translate('+(width/2)+','+(height/2)+')');
var moustache = [
{source: {x: 250, y: 100}, target: {x: 500, y: 90}},
{source: {x: 500, y: 90}, target: {x: 250, y: 120}},
{source: {x: 250, y: 120}, target: {x: 0, y: 90}},
{source: {x: 0, y: 90}, target: {x: 250, y: 100}},
{source: {x: 500, y: 90}, target: {x: 490, y: 80}},
{source: {x: 0, y: 90}, target: {x: 10, y: 80}}
];
我们开始在绘图区域上创建一个新的图形,并定义了一些应该创建一个甜美的胡子的数据:
g4.selectAll('path')
.data(moustache)
.enter()
.append('path')
.attr("d", d3.svg.diagonal())
.attr({stroke: 'black',
fill: 'none'});
剩下的只是一个简单地将数据连接到我们的绘图并使用d3.svg.diagonal()
生成器为d
属性:
好吧,这有点像达利式的画风。可能吧,但它实际上看起来并不像胡子。这是因为定义贝塞尔曲线弯曲方式的切线被调整以在树状图中创建好看的扇出效果。不幸的是,d3.js 并没有给我们提供简单更改这些设置的方法,而且通过 SVG 的路径迷你语言手动定义贝塞尔曲线最多也只能算是繁琐。
无论哪种方式,我们都创建了一个路径生成器的并排比较:
轴
但我们还没有对我们的路径和形状做任何有用的事情。我们可以通过使用线条和文本来创建图形轴来实现这一点。但这会很繁琐,所以 d3.js 通过轴生成器使我们的工作变得更简单。它们负责绘制线条、添加刻度、添加标签、均匀地间隔它们等等。
d3.js 轴只是配置为酷炫的路径生成器的组合。对于简单的线性轴,我们只需要创建一个刻度并告诉轴使用它。就是这样!
对于更定制的轴,我们可能需要定义所需的刻度数并指定标签,也许甚至更有趣。甚至有方法制作圆形轴。
使用一个全新的环境版本,让我们创建一个轴。
我们从一个绘图区域开始:
var width = 800,
height = 600,
margin = 20,
svg = d3.select('#graph')
.append('svg')
.attr({width: width,
height: height});
我们还需要一个线性刻度:
var x = d3.scale.linear().domain([0, 100]).range([margin, width-margin]);
我们的轴将使用以下方式将数据点(域
)转换为坐标(范围
):
var axis = d3.svg.axis()
.scale(x);
var a = svg.append('g')
.attr('transform', 'translate(0, 30)')
.data(d3.range(0, 100))
.call(axis);
我们已经告诉 d3.svg.axis()
生成器使用我们的 x
刻度。然后,我们简单地创建了一个新的分组元素,连接了一些数据,并调用了轴。同时调用 axis
生成器对所有数据非常重要,这样它就可以处理附加自己的元素。
结果看起来一点也不好。
轴是复杂对象,所以没有 CSS 的情况下解决这个问题很复杂,CSS 将在下一节中介绍。
目前,添加以下代码就足够了:
a.selectAll('path')
.attr({fill: 'none',
stroke: 'black',
'stroke-width': 0.5});
a.selectAll('line')
.attr({fill: 'none',
stroke: 'black',
'stroke-width': 0.3});
轴是一系列路径和线条;我们给它们一些风格,就能得到一个看起来很棒的轴:
如果你调整刻度数量,确保刻度范围的域和范围的最大值匹配,你会发现轴足够智能,总是能选择完美的刻度数量。
让我们比较不同设置对轴的影响。我们将遍历几个轴并渲染相同的数据。
通过在 svg.append('g')
之上添加这一行来将你的轴绘制代码包裹在一个循环中。别忘了在最后一个 stroke-width
之后关闭循环:
axes.forEach(function (axis, i) {
你也应该将 .attr('transform', …)
这一行改为将每个轴放置在上一轴下方 50 像素的位置。
.attr('transform', 'translate(0, '+(i*50+margin)+')')
现在已经完成了,我们可以开始定义一个轴数组:
var axes = [
d3.svg.axis().scale(x),
d3.svg.axis().scale(x)
.ticks(5)
];
目前有两个版本:一个是普通版本,另一个将渲染出恰好 5
个刻度:
它成功了!axis
生成器已经找到了哪些刻度最好不显示,并重新标记了所有内容,我们几乎没做什么。
让我们在数组中添加更多轴并看看会发生什么:
d3.svg.axis().scale(x)
.tickSubdivide(3)
.tickSize(10, 5, 10)
使用 .tickSubdivide()
,我们指示生成器在主刻度之间添加一些细分;.tickSize()
告诉它使次刻度更小。参数是主要、次要和末端刻度大小:
对于我们的最后一个技巧,让我们定义一些自定义刻度并将它们放置在轴上方。我们将在数组中添加另一个轴:
d3.svg.axis().scale(x)
.tickValues([0, 20, 50, 70, 100])
.tickFormat(function (d, i) {
return ['a', 'e', 'i', 'o', 'u'][i];
})
.orient('top')
这里发生了三件事:.tickValues()
精确定义了哪些值应该有刻度,.tickFormat()
指定了标签的渲染方式——顺便说一句,d3 在 d3.format
中提供了一系列有用的格式化工具——最后 .orient('top')
将标签放在轴的上方。
你可能已经猜到了默认方向是 'bottom'
。对于垂直轴,你可以使用 'left'
或 'right'
,但别忘了分配适当的刻度。
CSS
层叠样式表(Cascading Stylesheets)自 1996 年以来一直伴随着我们,使它们成为网络中最古老的基石之一,尽管它们直到 21 世纪初的表格与 CSS 战争才达到广泛的流行。
你可能熟悉使用 CSS 为 HTML 添加样式。所以,在所有关于 SVG 的内容之后,这一节将会像一阵清新的微风。
我最喜欢的 CSS 之处在于它的简单性;参考以下代码。
selector {
attribute: value;
}
就这样。关于 CSS 的所有你需要知道的内容都在三行之内。
选择器可能会相当复杂,超出了本书的范围。我建议在网上寻找一个好的指南。我们只需要了解一些基础知识:
-
path
: 选择所有<path>
元素 -
.axis
: 选择所有具有class="axis"
属性的元素 -
.axis line
: 选择所有是class="axis"
元素子元素的<line>
元素 -
.axis, line
: 选择所有class="axis"
和<line>
元素
现在,你可能正在想,“哦,嘿!这和 d3.js 选择器的选择器是一样的。”是的!它确实是完全一样的。d3.js 选择器是 CSS 选择器的一个子集。
我们可以通过三种方式使用 d3.js 调用 CSS:使用 .attr()
方法定义一个类属性,这可能会很脆弱;使用 .classed()
方法,这是定义类的首选方式;或者直接使用 .style()
方法定义样式。
让我们改进之前的轴示例,并使样式更简洁。
进入 HTML 并在 <div id="graph">
标签之前添加一些 CSS,如下所示:
<style>
.axis path,
.axis line {
fill: none;
stroke: black;
stroke-width: 1px;
shape-rendering: crispEdges;
}
.axis text {
font-size: 11px;
}
.axis.red line,
.axis.red path {
stroke: red;
}
</style>
它与直接更改 SVG 属性非常相似,但使用 CSS。我们使用 stroke
和 fill
来定义线的形状,并将 shape-rendering
设置为 crispEdges
。这将使事情变得更好。
我们还定义了一种带有红色线条的额外类型的轴。
现在我们将绘图循环修正如下:
axes.forEach(function (axis, i) {
var a = svg.append('g')
.classed('axis', true)
.classed('red', i%2 == 0)
.attr('transform', 'translate(0, '+(i*50+margin)+')')
.data(d3.range(0, 100))
.call(axis);
});
没有必要连续五次指定相同的样式。使用 .classed()
函数,我们为每个轴添加 axis
类,每隔一个轴是红色。.classed()
如果第二个参数为真则添加指定的类,否则移除。
颜色
美观的视觉化往往涉及比你能立刻想到的基本名称更多的颜色。有时你想要根据数据的样子来玩弄颜色。
d3.js 提供了一系列用于在四个流行的颜色空间中操作颜色的函数:RGB、HSL、HCL 和 Lab。对我们最有用的是 RGB(红绿蓝)和 HSL(色调饱和度亮度),这实际上是另一种查看 RGB 的方式。无论如何,所有颜色空间都使用相同的函数,所以你可以使用最适合你需求的。
要构建一个 RGB 颜色,我们使用 d3.rgb(r, g, b)
,其中 r
、g
和 b
分别指定红色、绿色和蓝色的通道值。我们也可以用简单的 CSS 颜色参数替换三元组。然后我们可以使颜色变得更暗或更亮,这比手动着色要好得多。
是时候在新的环境中玩转颜色了。我们将绘制两个颜色轮,它们的亮度从中心向外部逐渐变化。
和往常一样,我们从一个变量和一个绘图区域开始:
var width = 1024,
height = 768,
rings = 15;
var svg = d3.select('#graph')
.append('svg')
.style({width: width,
height: height});
从此以后,主要变量将是rings
;它将告诉代码我们想要多少亮度级别。我们还需要一些基本颜色和计算角度的方法:
var colors = d3.scale.category20b();
var angle = d3.scale.linear().domain([0, 20]).range([0, 2*Math.PI]);
colors
在技术上是一个比例尺,但我们将它用作数据。Category20b 是 d3.js 附带的前定义颜色比例尺之一——一种获取精选颜色列表的简单方法。
为了计算角度,我们使用一个线性比例尺,它将[0, 20]
域映射到完整的圆圈[0, 2*pi]
。
接下来我们需要一个arc
生成器和两个数据访问器来改变每个环的颜色色调:
var arc = d3.svg.arc()
.innerRadius(function (d) { return d*50/rings; })
.outerRadius(function (d) { return 50+d*50/rings; })
.startAngle(function (d, i, j) { return angle(j); })
.endAngle(function (d, i, j) { return angle(j+1); });
var shade = {
darker: function (d, j) { return d3.rgb(colors(j)).darker(d/rings); },
brighter: function (d, j) { return d3.rgb(colors(j)).brighter(d/rings); }
};
弧将根据简单的环计数器计算内半径和外半径,角度将使用angle
比例尺,这将自动计算正确的弧度值。j
参数告诉我们当前正在绘制哪个弧段。
由于我们制作了两幅图,我们可以通过使用字典中的两个不同的着色器来简化代码。
每个着色器将从一个颜色比例尺中获取d3.rgb()
颜色,然后根据它绘制的环数相应地变暗或变亮。再次强调,j
参数告诉我们我们处于哪个弧段,d
参数告诉我们我们处于哪个环。
最后,我们绘制两个颜色轮:
[[100, 100, shade.darker],
[300, 100, shade.brighter]].forEach(function (conf) {
svg.append('g')
.attr('transform', 'translate('+conf[0]+', '+conf[1]+')')
.selectAll('g')
.data(colors.range())
.enter()
.append('g')
.selectAll('path')
.data(function (d) { return d3.range(0, rings); })
.enter()
.append('path')
.attr("d", arc)
.attr('fill', function (d, i, j) { return conf2; });
});
哇!这段代码相当多。
我们取两个三元组,每个三元组定义了颜色轮的位置和要使用的着色器;然后调用一个函数,用每个着色器绘制一个闪亮的彩色圆圈。
对于每个圆圈,我们添加一个<g>
元素并将其移动到位置,然后使用colors.range()
获取完整的颜色列表并将其作为数据连接。对于每个新的颜色,我们创建另一个<g>
元素并选择它包含的所有<path>
元素。
这里事情变得神奇。我们连接了更多的数据,但这次只是一个从0
到rings
的数字数组。对于这个数组中的每个元素,我们添加一个<path>
元素并使用arc
生成器定义其形状。最后,我们使用适当的阴影颜色计算fill
属性。
结果看起来如下:
我们的主要技巧是将数据的第二维连接起来,通过提供给数据访问器的第三个属性保留了第一维的知识。
摘要
哇!在这一章中我们已经学到了很多。
你现在应该已经牢固掌握了构建优秀可视化所需的基本知识。我们学习了 DOM 操作,并详细探讨了 SVG,从手动绘制形状到路径生成器。最后,我们探讨了 CSS 作为使事物更美观的更好替代方案。
从现在开始,我们所看到的一切都将建立在这些基础知识之上,但你现在有了绘制任何你能想到的东西的工具。这本书的其余部分只是展示了更多优雅的实现方式。
第三章:使数据有用
在核心上,d3.js 是一个数据操作库。我们将探讨如何使用 d3.js 和普通的 JavaScript 使我们的数据集变得有用。
我们首先快速了解一下函数式编程,以便让每个人都能跟上进度。如果你使用 Haskell、Scala 或 Lisp,或者已经以函数式风格编写 JavaScript,你可以跳过这部分内容。
我们继续加载外部数据,更仔细地研究我无法停止谈论的刻度,并以一些时间和地理数据结束。
以函数式思维考虑数据
由于 d3.js 的函数式设计,我们不得不开始用函数式思维来思考我们的代码和数据。
好消息是,JavaScript 几乎可以算作是一种函数式语言;它有足够的功能来获得函数式风格的好处,同时也提供了足够的自由度,可以以命令式或面向对象的方式做事。坏消息是,与真正的函数式语言不同,环境对我们的代码没有任何保证。
在本节中,我们将介绍函数式编程的基础,并查看如何处理数据,使其更容易处理。如果你想尝试真正的函数式编程,我建议查看 Haskell 和 learnyouahaskell.com/
上的 Learn You a Haskell for Great Good。
函数式编程背后的想法很简单——通过仅依赖函数参数来计算。简单,但其后果是深远的。
最大的后果是我们不必依赖于状态,这反过来又给了我们引用透明性。这意味着使用相同参数执行的功能将始终给出相同的结果,无论何时或如何调用。
实际上这意味着我们设计代码和数据流,即获取数据作为输入,执行一系列函数,将更改后的数据传递到链中,并最终得到一个结果。
你在之前的例子中已经见过这个了。
我们的数据集开始和结束都是一个值数组。我们对每个项目执行了一些操作,并且在决定做什么时只依赖于当前项。我们还拥有当前索引,因此我们可以通过向前和向后查看流来稍微作弊,采用命令式方法。
内置数组函数
JavaScript 带有一系列数组操作函数。我们将关注那些更具有函数性质的函数,即迭代方法。
有位智者曾告诉我,你可以通过使用 map
和 reduce
来模拟任何算法。但他错了。你需要的是递归,一种将两个数组相加的方法,获取数组中第一和第二个元素的能力,一个相等比较器,以及决定某物是值还是数组的方法。实际上这正是 LISP 的定义方式。
但通过结合使用 map
、reduce
和 filter
以及它们的谓词,你将能走得很远。
map
命令对数组中的每个元素应用一个函数,并返回一个包含更改后值的新数组:
> [1,2,3,4].map(function (d) { return d+1; })
[ 2, 3, 4, 5 ]
reduce
函数使用一个组合函数和一个起始值来将数组折叠成一个单一值:
> [1,2,3,4].reduce(function (accumulator, current) { return accumulator+current; }, 0)
10
filter
函数遍历一个数组,并保留那些谓词返回 true
的元素:
> [1,2,3,4].filter(function (d) { return d%2; })
[ 1, 3 ]
两个更有用的函数是 .every()
和 .some()
,如果数组中的每个或某些元素都为真,则这两个函数返回真。有时,使用 .forEach()
而不是 .map()
更好,因为 forEach
在原始数组上操作而不是创建一个副本,这对于处理大型数组非常重要,并且主要用于副作用。
这些函数相对较新,而 map
和 filter
自 JavaScript 1.7 以来就存在了,reduce
自 1.8 以来就存在了;它们也是新兴的 ECMAScript 6 标准的一部分,因此不支持旧浏览器。您可以使用库,如 underscore.js 或各种 es6 shims 之一来支持旧浏览器。
d3.js 的数据函数
d3.js 自带了许多自己的数组函数。它们大多与处理数据有关;它包括计算平均值、排序、分割数组以及许多关联数组的辅助函数。
让我们玩一玩数据函数,并绘制一个未解决的数学问题——乌拉姆螺旋。乌拉姆螺旋是在 1963 年被发现的,它揭示了二维平面上质数分布的规律。到目前为止,还没有人找到解释它们的公式。
我们将通过模拟乌拉姆的笔和纸方法来构建螺旋;我们将以螺旋模式写下自然数,然后移除所有非质数。
我们不是用数字来画,而是用点。我们实验的第一阶段看起来如下:
看起来并不多,但这只是螺旋中的前 5,000 个质数。注意对角线吗?有些可以用多项式来描述,这带来了关于预测质数以及由此延伸出的密码学安全性的有趣含义。
我们从一个绘图区域开始:
var width = 768,
height = 768,
svg = d3.select('#graph')
.append('svg')
.attr({width: width,
height: height});
然后我们在函数的底部定义生成数字及其在网格上螺旋坐标的算法。我们开始时使用一些有用的变量:
var spiral = function (n) {
var directions = {up: [0, -1],
left: [-1, 0],
down: [0, 1],
right: [1, 0]};
var x = 0,
y = 0,
min = [0, 0],
max = [0, 0],
add = [0, 0],
direction = 0;
var spiral = [];
});
我们定义了一个 spiral
函数,它接受一个单一的界限参数 n
。该函数从四个旅行方向和一些算法变量开始。min
和 max
已知坐标的组合将告诉我们何时转弯;x
和 y
将是当前位置,而 direction
将告诉我们我们正在追踪螺旋的哪个部分。
接下来我们将算法本身添加到函数的底部:
d3.range(1, n).forEach(function (i) {
spiral.push({x: x, y: y, n: i});
add = directions[['up', 'left', 'down', 'right'][direction]];
x += add[0], y += add[1];
if (x < min[0]) {
direction = (direction+1)%4;
min[0] = x;
}
if (x > max[0]) {
direction = (direction+1)%4;
max[0] = x;
}
if (y < min[1]) {
direction = (direction+1)%4;
min[1] = y;
}
if (y > max[1]) {
direction = (direction+1)%4;
max[1] = y;
}
});
return spiral;
d3.range()
生成一个介于两个参数之间的数字数组,我们用 forEach
来迭代。每次迭代都会将一个新的 {x: x, y: y, n: i}
三元组添加到螺旋数组中。其余的只是使用 min
和 max
来改变螺旋角落的方向。是的,这很重复,但并不总是需要我们很聪明。
现在我们开始绘制:
var dot = d3.svg.symbol().type('circle').size(3),
center = 400,
x = function (x, l) { return center+l*x; },
y = function (y, l) { return center+l*y; };
我们定义了一个dot
生成器,以及两个帮助我们将螺旋函数中的网格坐标转换为像素位置的函数。l
是网格中一个方块的长度和宽度。
我们可以通过在线获取一个列表来避免计算素数的繁琐工作。我在www.mathsisfun.com/
找到一个列表,并将其放在 GitHub 上,与代码示例github.com/Swizec/d3.js-book-examples/blob/master/ch3/primes-to-100k.txt
并列放置。
d3.text('primes-to-100k.txt', function (data) {
var primes = data.split('\n').slice(0, 5000).map(Number),
sequence = spiral(d3.max(primes)).filter(function (d) {
return _.indexOf(primes, d['n'], true) > -1;
});
var l = 2;
svg.selectAll('path')
.data(sequence)
.enter()
.append('path')
.attr('transform',
function (d) { return 'translate('+x(d['x'], l)+', '+y(d['y'], l)+')'; })
.attr('d', dot);
});
我们将素数作为文本文件加载,将其拆分为行,使用.slice()
获取前5000
个元素,然后使用.map(Number)
将它们转换为数字。我们将使用l
来告诉x
和y
函数网格有多大。
我们用列表中的最大素数(d3.max()
)调用spiral
,生成数字的螺旋序列,然后使用.filter()
从螺旋中移除所有非素数,当它们被输入到绘图代码中时。
我们使用 underscore.js 的_.indexOf
来搜索素数,因为它使用二分搜索,使我们的代码更快。但要注意,我们必须知道我们的列表是有序的。你可以从underscorejs.org
获取 underscore.js。
我那台老机器仍然需要大约两秒钟的时间来绘制有趣的像素化图像。
让我们通过可视化素数的密度来让它更有趣。我们将定义一个具有更大方块的网格,然后根据它们包含的点数来着色。当素数少于中位数时,方块为红色,当素数多于中位数时,方块为绿色。阴影将告诉我们它们离中位数有多远。
首先,我们将使用 d3.js 的nest
结构定义一个新的网格:
var scale = 8;
var regions = d3.nest()
.key(function (d) { return Math.floor(d['x']/scale); })
.key(function (d) { return Math.floor(d['y']/scale); })
.rollup(function (d) { return d.length; })
.map(sequence);
我们以8
的倍数进行缩放,也就是说,每个新的方块包含 64 个旧的方块。
d3.nest()
根据一个键将数据转换为嵌套字典,这非常方便。第一个.key()
函数创建我们的列;每个x
都映射到新网格中对应的x
。第二个.key()
函数对y
做同样的处理。然后我们使用.rollup()
将结果列表转换成一个单一值,即点的数量。
数据通过.map()
输入,我们得到以下结构:
{
"0": {
"0": 5,
"-1": 2
},
"-1": {
"0": 3,
"-1": 4
}
}
这不是很直观,但这是一个包含行的列的集合。(0, 0)
的方块包含5
个素数,(-1, 0)
包含2
,以此类推。
要得到中位数和阴影数量,我们需要在数组中有这些计数:
var values = d3.merge(d3.keys(regions).map(function (_x) {
return d3.values(regions[_x]);
}));
var median = d3.median(values),
extent = d3.extent(values),
shades = (extent[1]-extent[0])/2;
我们通过遍历区域的键(x
坐标)来获取每个列的值列表,然后使用d3.merge()
将结果数组的数组展平。
d3.median()
给我们数组的中间值,d3.extent()
给我们最低和最高的数字,我们用这些来计算所需的阴影数量。
最后,我们再次遍历坐标来为新网格着色:
d3.keys(regions).forEach(function (_x) {
d3.keys(regions[_x]).forEach(function (_y) {
var color,
red = '#e23c22',
green = '#497c36';
if (regions[_x][_y] > median) {
color = d3.rgb(green).brighter(regions[_x][_y]/shades);
}else{
color = d3.rgb(red).darker(regions[_x][_y]/shades);
}
svg.append('rect')
.attr({x: x(_x, a*scale),
y: y(_y, a*scale),
width: a*scale,
height: a*scale})
.style({fill: color,
'fill-opacity': 0.9});
});
});
我们的形象看起来像那些 Chiptunes 专辑封面:
加载数据
d3.js 最伟大的特性之一是它可以在没有任何第三方库或程序员的帮助下异步加载外部数据。我们已经浏览了数据加载,但现在需要更仔细地研究。
我们想要从外部加载数据的原因是,将大量数据集通过预定义变量加载到页面中并不太实用。加载数百千字节的数据需要一段时间,而异步加载则允许在同时渲染页面的其余部分。
为了进行 HTTP 请求,d3.js 使用 XMLHttpRequests(简称 XHR)。由于浏览器的安全模型,这限制了我们只能访问单个域名,但如果服务器发送了Access-Control-Allow-Origin: *
头部,我们就可以进行跨域请求。
核心内容
所有这些加载的核心是谦逊的d3.xhr()
,这是手动发出 XHR 请求的方式。
它需要一个 URL 和一个可选的回调函数。如果存在,回调函数将立即触发请求,并在请求完成后将数据作为参数接收。
如果没有回调函数,我们可以调整请求;从头部到请求方法的一切都可以调整。
要进行请求,你可能需要编写以下代码:
var xhr = d3.xhr('<a_url>');
xhr.mimeType('application/json');
xhr.header('User-Agent', 'our example');
xhr.on('load', function (request) { … });
xhr.on('error', function (error) { … });
xhr.on('progress', function () { … });
xhr.send('GET');
这将发送一个期望 JSON 响应的 GET 请求,并告诉服务器我们是一个示例。缩短这一点的办法是立即定义一个回调,但这样就不能定义自定义头部或监听其他请求事件。
另一种方法是便利函数。我们将在整本书中使用这些函数。
便利函数
d3.js 附带了一些便利函数,这些函数在幕后使用d3.xhr()
,并在将其返回给我们之前解析响应。这使得我们可以将我们的工作流程限制为调用适当的函数并定义一个回调,该回调接受一个error
和一个data
参数。d3.js 足够好,让我们可以抛掉谨慎,使用只有一个data
参数的回调,在出错的情况下该参数将是未定义的。
我们可以选择多种数据格式,如 TEXT、JSON、XML、HTML、CSV 和 TSV。JSON 和 CSV/TSV 使用得最多:JSON 用于小型结构化数据,CSV/TSV 用于我们想要节省空间的大型数据转储。
我们的大部分代码将遵循这种模式:
d3.json('a_dataset.json', function (data) {
// draw stuff
});
规模
规模是映射定义域到值域的函数。是的,我一直在说这个,但真的没有更多可说的。
我们使用它们的原因是为了避免数学。这使得我们的代码更短,更容易理解,并且更健壮,因为高中数学中的错误是一些最难追踪的 bug。
如果你没有在学校里听过四年的数学,一个函数的定义域是它定义的值(输入),值域是它返回的值。
下面的图是从维基百科借用的:
在这里,X是定义域,Y是值域,箭头是函数。
我们需要一大堆代码来手动实现这一点:
var shape_color = function (shape) {
if (shape == 'triangle') {
return 'red';
}else if (shape == 'line') {
return 'yellow';
}else if (shape == 'pacman') {
return 'green';
}else if (shape == 'square') {
return 'red';
}
};
您也可以使用字典来做,但 d3.scale
总是更加优雅和灵活:
var scale = d3.scale.ordinal()
.domain(['triangle', 'line', 'pacman', 'square'])
.range(['red', 'yellow', 'green', 'red']);
好多了!
规模分为三种类型;序数尺度具有离散域,定量尺度具有连续域,时间尺度具有基于时间的连续域。
序数尺度
序数尺度是最简单的,本质上就是一个字典,其中键是域,值是范围。
在前面的例子中,我们通过显式设置输入域和输出范围来定义序数尺度。如果我们不定义域,它将从使用中推断出来,但这可能会产生不可预测的结果。
序数尺度的一个有趣之处在于,当范围小于域时,尺度会循环值。此外,如果范围只是 ['red', 'yellow', 'green']
,我们也会得到相同的结果。但是,将连续区间切割成块可以创建一个更好的范围,例如直方图。
让我们试试。
首先,我们需要一个绘图区域:
var width = 800,
height = 600,
svg = d3.select('#graph')
.append('svg')
.attr({width: width,
height: height});
然后我们定义了我们需要的三个尺度,并生成了一些数据:
var data = d3.range(30),
colors = d3.scale.category10(),
points = d3.scale.ordinal().domain(data)
.rangePoints([0, height], 1.0),
bands = d3.scale.ordinal().domain(data)
.rangeBands([0, width], 0.1);
我们的数据只是一个包含 30
个数字的列表,而 colors
尺度来自第二章,DOM、SVG 和 CSS 入门。它是一个预定义的序数尺度,具有未定义的域和十个颜色的范围。
然后我们定义了两个尺度,将我们的绘图分成相等的部分。points
使用 .rangePoints()
在绘图高度上分布 30
个等间距的点。我们使用因子 1.0
设置边缘填充,这设置了最后一个点到边缘的距离为点之间距离的一半。端点使用 point_distance*padding/2
从范围边缘向内移动。
bands
使用 .rangeBands()
将宽度划分为 30
个等宽的带,带之间的填充因子为 0.1
。这次我们设置带之间的距离,使用 step*padding
,第三个参数将使用 step*outerPadding
设置边缘填充。
我们将使用您在第二章中已经了解的代码,DOM、SVG 和 CSS 入门,来使用这些尺度绘制两条线:
svg.selectAll('path')
.data(data)
.enter()
.append('path')
.attr({d: d3.svg.symbol().type('circle').size(10),
transform: function (d) {
return 'translate('+(width/2)+', '+points(d)+')'; }
})
.style('fill', function (d) { return colors(d); });
svg.selectAll('rect')
.data(data)
.enter()
.append('rect')
.attr({x: function (d) { return bands(d); },
y: height/2,
width: bands.rangeBand(),
height: 10})
.style('fill', function (d) { return colors(d); });
要获取每个点或矩形的坐标,我们调用尺度作为函数,并使用 bands.rangeBand()
获取矩形宽度。
图片看起来如下:
定量尺度
定量尺度有多种不同的风味,但它们都有一个共同的特征,即输入域是连续的。连续尺度可以用一个简单的函数来建模,而不是一组离散值。七种定量尺度是线性、恒等、幂、对数、量化、分位数和阈值。它们定义了输入域的不同转换。前四种具有连续的输出范围,而后三种映射到离散范围。
为了了解它们的行为,我们将使用所有这些刻度在绘制weierstrass
函数时操纵y
坐标;这是第一个在所有地方连续但无处可微分的函数。这意味着尽管你可以不抬起笔来绘制函数,但你永远无法定义你正在绘制的角度(计算导数)。
我们从一个绘图区域和维基百科上找到的weierstrass
函数开始,如下所示:
var width = 800,
height = 600,
svg = d3.select('#graph')
.append('svg')
.attr({width: width,
height: height});
var weierstrass = function (x) {
var a = 0.5,
b = (1+3*Math.PI/2)/a;
return d3.sum(d3.range(100).map(function (n) {
return Math.pow(a, n)*Math.cos(Math.pow(b, n)*Math.PI*x);
}));
};
一个绘图函数将帮助我们避免代码重复:
var draw_one = function (line) {
return svg.append('path')
.datum(data)
.attr("d", line)
.style({'stroke-width': 2,
fill: 'none'});
};
我们生成一些数据,获取weierstrass
函数的extent
,并使用线性刻度来表示x
:
var data = d3.range(-100, 100).map(function (d) { return d/200; }),
extent = d3.extent(data.map(weierstrass)),
colors = d3.scale.category10(),
x = d3.scale.linear().domain(d3.extent(data)).range([0, width]);
连续范围刻度
我们可以使用以下代码来绘制:
var linear = d3.scale.linear().domain(extent).range([height/4, 0]),
line1 = d3.svg.line()
.x(x)
.y(function(d) { return linear(weierstrass(d)); });
draw_one(line1)
.attr('transform', 'translate(0, '+(height/16)+')')
.style('stroke', colors(0));
我们定义了一个线性刻度,其定义域包含weierstrass
函数返回的所有值,范围从零到绘图宽度。该刻度将使用线性插值在输入和输出之间进行转换,甚至可以预测其定义域之外的价值。如果我们不希望发生这种情况,我们可以使用.clamp()
。在定义域和范围内使用超过两个数字,我们可以创建一个多线性刻度,其中每个部分都像是一个独立的线性刻度。
线性刻度创建了以下截图:
让我们一次性添加其他连续刻度:
var identity = d3.scale.identity().domain(extent),
line2 = line1.y(function (d) { return identity(weierstrass(d)); });
draw_one(line2)
.attr('transform', 'translate(0, '+(height/12)+')')
.style('stroke', colors(1));
var power = d3.scale.pow().exponent(0.2).domain(extent).range([height/2, 0]),
line3 = line1.y(function (d) { return power(weierstrass(d)); });
draw_one(line3)
.attr('transform', 'translate(0, '+(height/8)+')')
.style('stroke', colors(2));
var log = d3.scale.log().domain(
d3.extent(data.filter(function (d) { return d > 0 ? d : 0; }))).range([0, width]),
line4 = line1.x(function (d) { return d > 0 ? log(d) : 0; })
.y(function (d) { return linear(weierstrass(d)); });
draw_one(line4)
.attr('transform', 'translate(0, '+(height/4)+')')
.style('stroke', colors(3));
我们继续重复使用相同的line
定义,改变用于y
的刻度,除了power
刻度,因为改变x
可以更好地说明例子。
我们还考虑了log
仅在正数上有定义,但你通常不会用它来表示周期函数。它在同一张图上显示大数和小数方面做得更好。
现在我们的图片看起来如下所示:
identity
刻度是橙色,几乎不跳动,因为我们输入到函数中的数据只从-0.5 到 0.5,power
刻度是绿色,而logarithmic
刻度是红色。
离散范围刻度
我们比较中有趣的刻度是quantize
和threshold
。quantize
刻度将输入域划分为相等的部分,并将它们映射到输出范围中的值,而threshold
刻度允许我们将任意域部分映射到离散值:
var quantize = d3.scale.quantize().domain(extent)
.range(d3.range(-1, 2, 0.5).map(function (d) { return d*100; })),
line5 = line1.x(x).y(function (d) { return quantize(weierstrass(d)); }),
offset = 100
draw_one(line5)
.attr('transform', 'translate(0, '+(height/2+offset)+')')
.style('stroke', colors(4));
var threshold = d3.scale.threshold().domain([-1, 0, 1]).range([-50, 0, 50, 100]),
line6 = line1.x(x).y(function (d) { return threshold(weierstrass(d)); });
draw_one(line6)
.attr('transform', 'translate(0, '+(height/2+offset*2)+')')
.style('stroke', colors(5));
quantize
刻度将weierstrass
函数划分为 1 到 2 之间的离散值,步长为 0.5(-1, -0.5, 0,等等),而阈值将小于-1 的值映射到-50,-1 映射到 0,等等。
结果如下所示:
时间
你不理解时间。你可能认为你理解了,但你并没有。
下次当你想要将 3,600 秒加到时间戳上以推进一小时,或者基本上now+24*3600
就是明天时,请记住这一点。
时间是一个复杂的生物。一小时可以是 3600 秒或 3599 秒,如果有闰秒。明天可以是 23 到 25 小时,月份从 28 到 31 天不等,一年可以是 365 天或 366 天。有些十年比其他十年天数少。
考虑到许多数据集与时间紧密相关,这可能会成为一个大问题。你该如何处理时间呢?
幸运的是,d3.js 提供了一系列处理时间的功能。
格式化
你可以通过给d3.time.format()
提供一个格式字符串来创建一个新的格式化器。然后你可以用它将字符串解析为Date
对象,反之亦然。
整个语言都在 d3.js 的文档中解释,但让我们看看一些例子:
> format = d3.time.format('%Y-%m-%d')
> format.parse('2012-02-19')
Sun Feb 19 2012 00:00:00 GMT+0100 (CET)
我们使用d3.time.format()
定义了一个新的格式化器(年-月-日),然后解析了数据集中常见的日期。这给了我们一个带有默认小时、分钟和秒的适当date
对象。
同样的格式化器也可以反过来使用:
> format(new Date())
"2013-02-19"
你可以在d3.time.format.iso
找到完整的 ISO 标准时间格式化器。这通常很有用。
时间算术
我们还得到了一套完整的时间算术函数,它们与 JavaScript 的Date
对象一起工作,并遵循一些简单的规则:
-
d3.time.interval
,其中interval
可以是second
、minute
、hour
等。它返回一个新的时间间隔。例如,d3.time.hour
将是一个小时的长度。 -
d3.time.interval
(Date
)是一个interval.floor()
的别名,它将Date
向下取整,使得比interval
更具体的单位被设置为零。 -
interval.offset
(Date
,step
)将根据指定的步数将日期移动到正确的单位。 -
interval.range
(Date_start
,Date_stop
)将返回两个指定日期之间的每个interval
。 -
d3.time.intervals
,其中interval
是seconds
、minutes
、hours
等。它们是interval.range
的有用别名。
例如,如果你想找到一小时后的时间,你会这样做:
> d3.time.hour.offset(new Date(), 1)
Tue Feb 19 2013 06:09:17 GMT+0100 (CET)
发现已经很晚了,你应该停止写关于 JavaScript 的书,去睡觉了。
地理学
其他有用的数据类型是地理坐标,通常用于天气或人口数据;任何需要绘制地图的地方。
d3.js 为我们提供了三个用于地理数据的工具:路径生成最终像素,投影将球坐标转换为笛卡尔坐标,流加速处理。
我们将使用的主要数据格式是 TopoJSON,它是 GeoJSON 的一个更紧凑的扩展,由 Mike Bostock 创建。从某种意义上说,TopoJSON 是 GeoJSON 相对于视频的 DivX。虽然 GeoJSON 使用 JSON 格式来编码地理数据,包括点、线和多边形,但 TopoJSON 使用弧线来编码基本特征,并重复使用它们来构建越来越复杂的特征。因此,文件可以比使用 GeoJSON 时小 80%以上。
获取地理数据
现在,与许多其他数据集不同,地理数据不能在互联网上随意找到。特别是在像 TopoJSON 这样的边缘格式中。我们将找到一些 Shapefile 或 GeoJSON 格式的数据,然后使用topojson
命令行工具将它们转换为 TopoJSON。找到详细数据可能很困难,但并非不可能,查找您国家的统计局。例如,美国人口普查局在www.census.gov/geo/www/cob/index.html
提供了许多有用的数据集。
Natural Earth 是另一个提供不同细节级别地理数据的出色资源。最大的优势是不同的层(海洋、国家、道路等)被精心制作以相互匹配,没有差异,并且经常更新。您可以在www.naturalearthdata.com/
找到这些数据集。
让我们为下一个示例准备一些数据。访问www.naturalearthdata.com/
并下载 50 米细节级别的ocean
、land
、rivers and lake centerlines
和land boundary lines
数据集,以及 10 米的urban areas
数据集。您可以在下载标签页中找到它们。这些文件也位于 GitHub 上的示例中,可在github.com/Swizec/d3.js-book-examples/tree/master/ch3/data
找到。
解压这五个文件。我们将它们合并成三个 TopoJSON 文件以节省请求时间,三个大文件比五个小文件更快,我们更喜欢 TopoJSON,因为它文件大小更小。
我们将按类别合并,这样我们以后可以重用这些文件;一个用于水数据,另一个用于土地数据,第三个用于文化数据。
您需要安装topojson
,它需要 node.js。按照nodejs.org
上您电脑的安装说明进行操作,然后打开终端,并运行以下命令:
> npm install -global topojson
npm
是 node 的内置包管理器。它全局下载并安装topojson
库。您可能需要以超级用户身份运行此命令。
接下来,我们使用三个简单的命令转换这些文件:
> topojson -o water.json ne_50m_rivers_lake_centerlines.shp ne_50m_ocean.shp
> topojson -o land.json ne_50m_land.shp
> topojson -o cultural.json ne_50m_admin_0_boundary_lines.shp ne_10m_urban_areas.shp
topojson
库将形状文件转换为 TopoJSON 文件,并合并我们想要合并的文件。我们使用-o
指定了结果存放的位置;其他参数是源文件。
我们已生成三个文件:water.json
、land.json
和cultural.json
。您可以随意查看它们,但它们并不非常适合人类阅读。
绘制地理数据
d3.geo.path()
将成为我们地理绘图的得力助手。它与之前我们了解的 SVG 路径生成器类似,但它绘制的是地理数据,并且足够智能,能够决定是绘制线条还是区域。
为了将球形对象(如行星)展平为二维图像,d3.geo.path()
使用投影。不同的投影类型被设计来展示数据的不同方面,但最终结果是,你只需更改投影或移动其焦点,就可以完全改变地图的外观。
使用正确的投影,你甚至可以使欧洲的数据看起来像美国。遗憾的是,默认投影是专门为绘制标准地图而设计的albersUsa
。
让我们绘制一个以欧洲为中心并放大到欧洲的地图,因为这是我来自的地方。我们将在第四章让事物移动中使其可导航。
我们首先需要向我们的标准 HTML 文件中添加一些内容。
在主div
上方添加一个空的style
定义;我们稍后会用它来使我们的地图看起来更好:
<style></style>
我们还需要在 d3.js 之后立即添加两个更多的 JavaScript 文件:
<script src="img/topojson.v0.min.js"></script>
<script src="img/queue.v1.min.js"></script>
这些内容加载了 TopoJSON 解析器和队列工具,以帮助我们加载多个数据集。
我们在 JavaScript 文件中继续使用绘图区域:
var width = 1800,
height = 1200,
svg = d3.select('#graph')
.append('svg')
.attr({width: width,
height: height});
接下来,我们定义地理投影
:
var projection = d3.geo.equirectangular()
.center([8, 56])
.scale(800);
等角投影
是 d3.js 附带十二种投影之一,可能是我们自高中以来最常见的一种投影。
等角投影
的问题在于它不保留面积或很好地表示地球表面。关于将球体投影到二维表面的全面讨论需要太多时间,所以我建议查看 d3.js 的维基百科页面以及投影插件中实现的所有投影的可视比较。它可在github.com/mbostock/d3/wiki/Geo-Projections
找到。
接下来的两行定义了地图的中心位置和缩放程度。通过调整,我得到了三个值:纬度为8
,经度为56
,缩放因子为800
。尝试调整以获得不同的外观。
现在我们加载我们的数据:
queue()
.defer(d3.json, 'data/water.json')
.defer(d3.json, 'data/land.json')
.defer(d3.json, 'data/cultural.json')
.await(draw);
我们使用 Mike Bostock 的queue
库按顺序运行三个加载操作。每个操作都将使用d3.json
来加载和解析数据,当它们全部完成后,queue
将使用结果调用draw
。
在我们开始绘图之前,我们还需要一个函数,该函数可以向地图添加一个功能,这将帮助我们减少代码重复:
function add_to_map(collection, key) {
return svg.append('g')
.selectAll('path')
.data(topojson.object(collection,
collection.objects[key]).geometries)
.enter()
.append('path')
.attr('d', d3.geo.path().projection(projection));
}
此函数接受一个对象集合和一个键,用于选择要显示的对象。topojson.object()
将 TopoJSON 拓扑转换为 GeoJSON,以便d3.geo.path()
使用。
是否将数据转换为 GeoJSON 比在目标表示中传输数据更高效,取决于你的使用情况。转换数据需要一些计算时间,但将兆字节而不是千字节传输可以大大提高响应速度。
最后,我们创建一个新的 d3.geo.path()
,并告诉它使用我们的投影。除了生成 SVG 路径字符串外,d3.geo.path()
还可以计算我们特征的不同属性,如面积 (.area()
) 和边界框 (.bounds()
).
现在我们可以开始绘制:
function draw (err, water, land, cultural) {
add_to_map(water, 'ne_50m_ocean')
.classed('ocean', true);
};
我们的 draw
函数接收加载数据返回的错误,以及三个数据集,然后让 add_to_map
执行繁重的工作。
给 HTML 添加一些样式:
.ocean {
fill: #759dd1;
}
刷新页面将显示一些海洋。
我们在 draw
函数中添加了四个额外的 add_to_map
调用,以填充其他功能:
add_to_map(land, 'ne_50m_land')
.classed('land', true);
add_to_map(water, 'ne_50m_rivers_lake_centerlines')
.classed('river', true);
add_to_map(cultural, 'ne_50m_admin_0_boundary_lines_land')
.classed('boundary', true);
add_to_map(cultural, 'ne_10m_urban_areas')
.classed('urban', true);
添加以下样式定义:
.river {
fill: none;
stroke: #759dd1;
stroke-width: 1;
}
.land {
fill: #ede9c9;
stroke: #79bcd3;
stroke-width: 2;
}
.boundary {
stroke: #7b5228;
stroke-width: 1;
fill: none;
}
.urban {
fill: #e1c0a3;
}
现在我们有一个缓慢渲染的欧洲地区放大地图,显示世界城市区域作为斑点:
有很多原因导致它如此缓慢。我们在每次调用 add_to_map
时都在 TopoJSON 和 GeoJSON 之间转换。即使使用相同的数据集,我们也使用过于详细的数据来绘制如此缩放的地图,并且我们渲染整个世界来查看一个小部分。我们为了渲染速度而牺牲了灵活性。
使用地理作为基础
地理不仅仅是绘制地图。地图通常是我们构建的基础,用于展示一些数据。
让我们将其转换为世界机场地图。最初我想制作机场之间的路线图,但它看起来太拥挤了。
第一步是从 openflights.org/data.html
获取 airports.dat
和 routes.dat
数据集。你也可以在 GitHub 上的示例中找到它:github.com/Swizec/d3.js-book-examples/blob/master/ch3/data/airports.dat
。
在 draw
的底部添加对 add_airlines()
的调用。我们将使用它来加载更多数据并绘制机场:
function add_airlines() {
queue()
.defer(d3.text, 'data/airports.dat')
.defer(d3.text, 'data/routes.dat')
.await(draw_airlines);
};
函数加载了两个数据集,然后调用 draw_airlines
来绘制它们。我们使用 d3.text
而不是 d3.csv
,因为文件没有标题行,所以我们必须手动解析。
在 draw_airlines
中,我们首先将数据整理成 JavaScript 对象,通过 id
将机场整理成字典,并将路线整理成源到目标机场的映射:
function draw_airlines(err, _airports, _routes) {
var airports = {},
routes = {};
d3.csv.parseRows(_airports).forEach(function (airport) {
var id = airport[0];
airports[id] = {
lat: airport[6],
lon: airport[7]
};
});
d3.csv.parseRows(_routes).forEach(function (route) {
var from_airport = route[3];
if (!routes[from_airport]) {
routes[from_airport] = [];
}
routes[from_airport].push({
to: route[5],
from: from_airport,
stops: route[7]
});
});
}
我们使用 d3.csv.parseRows
将 CSV 文件解析成数组,并手动将它们转换为字典。不幸的是,数组索引不太易读,但当你查看原始数据时它们是有意义的:
1,"Goroka","Goroka","Papua New Guinea","GKA","AYGA",-6.081689,145.391881,5282,10,"U"
2,"Madang","Madang","Papua New Guinea","MAG","AYMD",-5.207083,145.7887,20,10,"U"
每个机场圆圈的半径将显示有多少路线从这里出发。因此,我们需要一个比例:
var route_N = d3.values(routes).map(function (routes) {
return routes.length;
}),
r = d3.scale.linear().domain(d3.extent(route_N)).range([2, 15]);
我们将路线计数数组转换为线性比例。
现在我们可以绘制机场:
svg.append('g')
.selectAll('circle')
.data(d3.keys(airports))
.enter()
.append('circle')
.attr("transform", function (id) {
var airport = airports[id];
return "translate("+projection([airport.lon, airport.lat])+")";
})
.attr('r', function (id) { return routes[id] ? r(routes[id].length) : 1; })
.classed('airport', true);
困难的部分在于我们使用了与d3.geo.path()
相同的projection
,将机场位置转换为圆坐标。我们避免了cx
和cy
属性,以便我们可以利用projection
同时处理两个坐标。到目前为止,其他所有内容都应该来自第二章, DOM、SVG 和 CSS 入门。
没有航线的机场将是非常小的点。
之后,我们在我们的 HTML 中添加了一些更多的 CSS:
.airport {
fill: #9e56c7;
opacity: 0.6;
stroke: #69349d;
}
以下截图显示了结果:
摘要
你已经完成了关于数据的章节!
我们真正触及了 d3.js 的核心,即数据处理。关于函数式编程的部分可能激发了你尝试函数式风格的编程,如果你还在犹豫不决的话。在了解数据处理的过程中,我们看到了一些素数的有趣属性,学习了如何加载外部数据,并有效地使用比例来避免计算。
最后,我们制作了一张酷炫的地图,来学习一旦掌握了良好的数据源并将其转换为更好的格式,简单的地理数据可以变得多么简单。
第四章 制作动态效果
一幅漂亮的图片只是开始!充分利用这种媒介的标志是制作能够适应新情况的视觉化。让用户能够探索我们的数据。
在本章中,我们将使用 d3.js 强大的转换模块来动画化我们的图片,并探讨一些与用户交互的策略。
使用转换进行动画
到目前为止,属性都是即时应用的,这对于渲染图像来说很棒,但如果我们想通过简单的动画突出显示某些内容呢?也许我们只想在加载数据时从无到“嘿,图表!”有一个更平滑的过渡?
这就是转换的作用。转换使用熟悉的改变选择属性的原则,但变化是随时间应用的。
要慢慢将矩形变成红色,我们会使用以下代码行:
d3.select('rect').transition().style('fill', 'red');
我们通过 .transition()
开始一个新的转换,然后定义每个动画属性的最终状态。默认情况下,每个转换需要 250 毫秒;你可以使用 .duration()
来更改时间。除非你使用 .delay()
设置延迟,否则新转换将同时执行所有属性。
当我们想要按顺序使转换发生时,延迟很有用。如果没有延迟,它们将同时执行,取决于内部计时器。
对于单个对象,嵌套转换比精心校准的延迟简单得多。
以我们的矩形为例,在 Chrome 控制台中写下类似的内容。如果你还没有这样做,你需要实际将一个矩形添加到页面上才能使这生效。生活就是这样。
d3.select('rect')
.transition().style('fill', 'red').attr('x', 200)
.transition().attr('y', 200)
运行这段代码,你会看到矩形向右移动一百像素后变成红色,然后向下移动相同的距离。
在屏幕截图中捕获动画很困难,但假设这是你的初始状态:
最终状态看起来会是这样:
我们确实意识到这些只是白色背景上的两个正方形,但请相信我,红色正方形在黑色正方形下方和右侧一百像素处。
如果你想在转换开始之前做某事,或者想监听它何时结束,你可以使用 .each()
并提供适当的事件类型,如下所示:
rect.transition()
.style('fill', 'red')
.each('start', function () { console.log("stahp, you're making me blush"); })
.each('end', function () { console.log("crap, I'm all red now"); })
这在在转换之前或之后进行即时更改时很有用。但请记住,转换是独立运行的,你不能依赖于当前回调之外的转换处于这种状态或那种状态。
插值器
要计算一个转换的初始状态和最终状态之间的值,d3.js 使用插值器——这些函数将 [0,1]
的域映射到目标范围(颜色、数字或字符串)。在底层,比例尺基于这些相同的插值器。
D3 的内置插值器可以在几乎任何两个任意值之间进行插值,最常见的是在数字或颜色之间,也可以在字符串之间。一开始这听起来很奇怪,但实际上非常实用。
为了让 d3.js 选择适合工作的正确插值器,我们只需编写 d3.interpolate(a, b)
,然后根据 b
的类型选择 interpolation
函数。
如果 b
是一个数字,a
将会被强制转换为数字,并使用 .interpolateNumber()
方法。你应该避免将值插值到或从零值,因为最终值会被转换为字符串属性,非常小的数字可能会变成科学记数法。CSS 和 HTML 并不完全理解 1e-7
(前面有七个零的数字 1),所以你可以安全使用的最小数字是 1e-6
。
如果 b
是一个字符串,d3.js 会检查它是否是 CSS 颜色,如果是,它会被转换为一个合适的颜色,就像 第二章 DOM、SVG 和 CSS 简介 中的那些一样。a
也会被转换成颜色,然后 d3.js 使用 .interpolateRgb()
或更适合你的颜色空间的插值器。
当字符串不是颜色时,还会发生更令人惊奇的事情。d3.js 也能处理这种情况!当它遇到字符串时,d3.js 会解析它以获取数字,然后对字符串中的每个数值部分使用 .interpolateNumber()
。这对于插值混合样式定义非常有用。
例如,要过渡字体定义,你可能做如下操作:
d3.select('svg')
.append('text')
.attr({x: 100, y: 100})
.text("I'm growing!")
.transition()
.styleTween('font', function () {
return d3.interpolate('12px Helvetica', '36px Comic Sans MS');
我们使用了 .styleTween()
来手动定义过渡。当不想依赖于当前状态来定义过渡的起始值时,这非常有用。第一个参数定义了要过渡的样式属性,第二个参数是插值器。
你可以使用 .tween()
方法来对除了样式以外的属性进行插值。
字符串中的每个数值部分都在起始值和结束值之间进行插值,而字符串部分则立即变为最终状态。这个应用的有趣之处在于插值路径定义——你可以使形状随时间变化。这有多酷?
请记住,只有具有相同数量和位置的控点(字符串中的数字)的字符串才能进行插值。你不能对一切使用插值器。创建一个自定义插值器就像定义一个只接受单个 t
参数并返回 t = 0
的起始值和 t = 1
的结束值,并在两者之间混合值的函数一样简单。
例如,以下代码显示了 d3.js 的 interpolateNumber
函数:
function interpolateNumber(a, b) {
return function(t) {
return a + t * (b - a);
};
}
就这么简单!
你甚至可以插值整个数组和对象,它们就像多个值复合插值器。我们很快就会使用它们。
缓动
缓动通过控制 t
参数来调整插值器的行为。我们使用它来使动画感觉更自然,添加一些弹跳弹性,等等。我们主要使用缓动来避免线性动画的人工感。
让我们快速比较 d3.js 提供的缓动函数,看看它们的作用。
不要忘记绘图区域!我曾经花了一个小时调试一个图表,直到意识到没有svg
元素。
var width = 1024,
height = 768,
svg = d3.select('#graph')
.append('svg')
.attr({width: width,
height: height});
接下来,我们需要一个缓动函数数组和一个用于在垂直轴上放置它们的缩放比例。
var eases = ['linear', 'poly(4)', 'quad', 'cubic', 'sin', 'exp', 'circle', 'elastic(10, -5)', 'back(0.5)', 'bounce', 'cubic-in', 'cubic-out', 'cubic-in-out', 'cubic-out-in'],
y = d3.scale.ordinal().domain(eases).rangeBands([50, 500]);
你会注意到poly
、elastic
和back
需要参数;由于这些只是字符串,我们稍后必须手动将它们更改为实际参数。poly
缓动函数只是一个多项式,所以poly(2)
等于quad
,poly(3)
等于cubic
。
elastic
缓动函数模拟弹性,两个参数控制张力。我建议尝试调整这些值以获得你想要的效果。
back
缓动函数旨在模拟倒车进入停车位。参数控制将有多少超调。
最后面的无意义内容(cubic-in
、cubic-out
等)是我们通过组合以下修饰符自己创建的缓动函数列表:
-
-in
:它什么都不做 -
-out
:它反转缓动方向 -
-in-out
:它从[0, 0.5]
和[0.5, 1]
复制并镜像缓动函数 -
-out-in
:它从[1, 0.5]
和[0.5, 0]
复制并镜像缓动函数
你可以将这些添加到任何缓动函数中,所以可以随意尝试。现在是时候渲染一个向右飞行的圆圈,为列表中的每个函数:
eases.forEach(function (ease) {
var transition = svg.append('circle')
.attr({cx: 130,
cy: y(ease),
r: y.rangeBand()/2-5})
.transition()
.delay(400)
.duration(1500)
.attr({cx: 400});
});
我们使用一个迭代器遍历列表,创建一个新的圆圈,并使用y()
缩放进行垂直定位,使用y.rangeBand()
进行圆圈大小。这样,我们可以轻松地添加或删除示例。过渡将延迟不到半秒开始,这样我们可以有机会看到正在发生的事情。持续时间为1500
毫秒,最终位置为400
应该有足够的时间和空间来观察缓动效果。
我们在这个函数的末尾定义缓动,在});
之前:
if (ease.indexOf('(') > -1) {
var args = ease.match(/[0-9]+/g),
type = ease.match(/^[a-z]+/);
transition.ease(type, args[0], args[1]);
}else{
transition.ease(ease);
}
这段代码检查ease
字符串中的括号,解析出缓动函数及其参数,并将它们传递给transition.ease()
。如果没有括号,ease
只是缓动类型。
让我们添加一些文本,这样我们就可以区分示例:
svg.append('text')
.text(ease)
.attr({x: 10,
y: y(ease)+5});
可视化是一堆点的嘈杂组合:
截图并不能很好地展示动画,所以你真的应该在浏览器中尝试这个。或者你可以查看easings.net/
上的缓动曲线。
计时器
为了安排过渡,d3.js 使用计时器。即使是立即过渡,也会在延迟 17 毫秒后开始。
d3.js 不仅将计时器留给自己,还允许我们使用计时器,这样我们就可以将动画扩展到过渡的两个关键帧模型之外。对于我们这些不是动画师的人来说,关键帧定义了平滑过渡的开始或结束。
要创建一个计时器,我们使用 d3.timer()
。它需要一个函数、一个延迟和一个起始标记。从标记开始设置延迟(以毫秒为单位)后,该函数将重复执行,直到它返回 true
。标记应该是一个自 Unix 纪元以来的日期转换为毫秒(Date.getTime()
可以做到),或者你可以让 d3.js 默认使用 Date.now()
。
让我们动画化一个参数函数的绘制,使其看起来就像你小时候可能玩过的斯皮罗图玩具。
我们将创建一个计时器,让它运行几秒钟,并使用毫秒标记作为参数函数的参数。
首先,我们需要一个绘图区域:
var width = 600,
height = 600,
svg = d3.select('#graph')
.append('svg')
.attr({width: width,
height: height});
我在维基百科关于参数方程的文章中找到一个很好的函数,链接为 en.wikipedia.org/wiki/Parametric_equations
。
var position = function (t) {
var a = 80, b = 1, c = 1, d = 80;
return {x: Math.cos(a*t) - Math.pow(Math.cos(b*t), 3),
y: Math.sin(c*t) - Math.pow(Math.sin(d*t), 3)};
};
这个函数将根据参数从零向上的变化返回一个数学位置。你可以通过改变 a
、b
、c
和 d
变量来调整斯皮罗图——同一维基百科文章中的示例。
这个函数返回介于 -2
和 2
之间的位置,因此我们需要一些刻度来使其在屏幕上可见:
var t_scale = d3.scale.linear().domain([500, 25000]).range([0, 2*Math.PI]),
x = d3.scale.linear().domain([-2, 2]).range([100, width-100]),
y = d3.scale.linear().domain([-2, 2]).range([height-100, 100]);
t_scale
将时间转换为函数的参数;x
和 y
将计算图像上的最终位置。
现在我们需要定义 brush
来四处移动并假装它在绘制,以及一个变量来保存 previous
位置,这样我们就可以绘制直线。
var brush = svg.append('circle')
.attr({r: 4}),
previous = position(0);
接下来,我们需要定义一个动画 step
函数,该函数移动刷子并在前一个和当前点之间绘制线条:
var step = function (time) {
if (time > t_scale.domain()[1]) {
return true;
}
var t = t_scale(time),
pos = position(t);
brush.attr({cx: x(pos.x),
cy: y(pos.y)});
svg.append('line')
.attr({x1: x(previous.x),
y1: y(previous.y),
x2: x(pos.x),
y2: y(pos.y),
stroke: 'steelblue',
'stroke-width': 1.3});
previous = pos;
};
第一个条件在 time
参数的当前值超出 t_scale
的定义域时停止计时器。然后,我们使用 t_scale()
将时间转换为我们的参数,并为刷子获取一个新的位置。
然后,我们移动刷子——因为没有过渡,因为我们正在执行过渡——并在前一个和当前位置 (pos
) 之间绘制一条新的钢蓝色线条。
我们通过设置新的 previous
位置来得出结论。
现在只剩下创建一个计时器:
var timer = d3.timer(step, 500);
就这样。页面刷新后半秒,代码将开始绘制一个美丽的形状,并在 25 秒后完成。
开始时,它看起来像这样:
获得整个图像需要一段时间,所以这可能不是绘制斯皮罗图的最好方法。由于我们使用时间作为参数,更平滑的曲线(更多点)需要更多时间。
另一个问题是有延迟的计算机或较慢的机器会影响动画的最终结果。
一位读者编写了一个没有这些问题的版本,并将代码放在了 Github 上,链接为 github.com/johnaho/d3.js-book-examples/blob/master/ch4/timers.js
。
但两种代码版本最终都会得到一个美丽的花朵。当我编写这段代码时,我花了整整一个小时惊叹于绘制过程并调整参数以查看会发生什么。
与用户交互
优秀的可视化不仅仅停留在漂亮的图片和动画上!它们赋予用户操作数据和自行解决问题的能力。这正是我们接下来要探讨的。
你可能还不知道,但你已经知道如何让用户与可视化进行交互。
基本交互
与其他 UI 库类似,交互的原则很简单——将事件监听器附加到元素上,并在触发时执行某些操作。我们使用.on()
方法、事件类型(例如,click
)和当事件被触发时执行的监听器函数,向和从选择中添加和删除监听器。
我们可以设置一个捕获标志,确保我们的监听器首先被调用,其他所有监听器都等待我们的监听器完成。从子元素冒泡上来的事件不会触发我们的监听器。
你可以依赖这样一个事实:一个元素上特定事件的监听器始终只有一个,因为当添加新监听器时,会移除相同事件的旧监听器。这对于避免不可预测的行为非常有用。
就像其他对元素选择执行操作的函数一样,事件监听器会获取当前数据项和索引,并将 this
上下文设置为 DOM 元素。全局的 d3.event
将允许你访问实际的事件对象。
让我们通过鼠标点击和手指触摸来玩转这些原则和简单的可视化。是的,d3.js 对触摸设备有一些支持,但并不总是完美。
和往常一样,从一个绘图区域开始:
var width = 1024,
height = 768,
svg = d3.select('#graph')
.append('svg')
.attr({width: width,
height: height});
接下来,我们创建一个函数,将使用三个圆模拟池塘中的涟漪;你可能需要一些想象力:
var radiate = function (pos) {
d3.range(3).forEach(function (d) {
svg.append('circle')
.attr({cx: pos[0],
cy: pos[1],
r: 0})
.style('opacity', '1')
.transition()
.duration(1000)
.delay(d*50)
.attr('r', 50)
.style('opacity', '0.00001')
.remove();
});
};
radiate
函数在由一个包含两个元素的数组定义的位置([x, y])周围创建三个圆。过渡效果会使圆变大,降低它们的透明度,最后移除它们。我们使用了 .delay
来确保圆不会重叠,从而产生涟漪的错觉。
现在是时候享受乐趣了:
svg.on('click', function () {
radiate(d3.mouse(this));
});
svg.on('touchstart', function () {
d3.touches(this).map(radiate);
});
我们为想要产生涟漪的每种事件类型使用 .on()
一次——首先是熟悉的 click
事件,然后是可能不太熟悉的 touchstart
。当手指触摸屏幕时,会触发 touchstart
事件;将其视为触摸的 mousedown
事件。其他有用的触摸事件包括 touchmove
、touchend
、touchcancel
和 tap
。Mozilla 的文档在 developer.mozilla.org/en-US/docs/Web/Guide/API/DOM/Events/Touch_events
中更详细地解释了触摸事件。
click
监听器使用 d3.mouse()
获取相对于容器元素的游标位置,而 touchstart
监听器通过所有触摸的列表进行映射。理论上,如果你将整个手压在屏幕上,这将绘制几个涟漪,但我无法在我的任何设备上实现这一点。
通过一些样式使涟漪变得漂亮:
<style>
circle {
fill: none;
stroke: red;
stroke-width: 2;
}
</style>
点击周围会产生涟漪!
行为
有时候,你想要的不仅仅是让用户像疯子一样点击;你想要拖放、缩放和缩放功能!
你可以通过点击事件完成所有这些,但我强烈推荐使用 d3 的行为模块。它使得复杂的行为变得像在元素上调用正确函数一样简单。
目前,d3.js 只支持 drag
和 zoom
,但我希望更多功能即将到来。行为的主要好处是它们自动创建相关的事件监听器,并允许你在更高的抽象级别上工作。
Drag
我想不出比用视差错觉动画更好的拖动演示了。错觉是通过在垂直切片中渲染几个关键帧来实现的,通过拖动屏幕覆盖它们来创建一个动画效果。
手动绘制线条会很麻烦,所以我们使用了一个由 Marco Kuiper 在 Photoshop 中创建的图像。我在推特上询问了他,他说如果我们查看他在 marcofolio.net 的其他作品,我们可以使用这张图片。
你也可以在示例仓库中找到这张图片:raw.github.com/Swizec/d3.js-book-examples/master/ch4/parallax_base.png
.
我们需要一个地方来放置视差效果:
var width = 1200,
height = 450,
svg = d3.select('#graph')
.append('svg')
.attr({width: width,
height: height});
我们将使用 SVG 的原生位图嵌入支持来将 parallax_base.png
插入页面:
svg.append('image')
.attr({'xlink:href': 'parallax_base.png',
width: width,
height: height});
image
元素的魔力源于其 xlink:href
属性。它理解链接,甚至允许我们嵌入图像以创建自包含的 SVG。要使用它,你需要在图像的 base64 编码表示之前添加一个图像 MIME 类型。
例如,以下行是最小的嵌入版 spacer GIF。如果你不知道什么是 spacer GIF,不用担心;它们在 2005 年左右之前很有用。
data:image/gif;base64,R0lGODlhAQABAID/AMDAwAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==
总之,现在我们有了动画的基础,我们需要一个可以拖动的屏幕。它将是一系列精心校准的垂直线条:
var screen_width = 900,
lines = d3.range(screen_width/6),
x = d3.scale.ordinal().domain(lines).rangeBands([0, screen_width]);
我们将基于数字数组(lines
)来构建屏幕。由于线条的粗细和密度非常重要,我们将 screen_width
除以 6
——五像素用于线条,一像素用于间距。确保 screen_width
的值是 6 的倍数;否则抗锯齿会破坏效果。
x
标度将帮助我们均匀地放置线条:
svg.append('g')
.selectAll('line')
.data(lines)
.enter()
.append('line')
.style('shape-rendering', 'crispEdges')
.attr({stroke: 'black',
'stroke-width': x.rangeBand()-1,
x1: function (d) { return x(d); },
y1: 0,
x2: function (d) { return x(d); },
y2: height});
这里没有什么特别有趣的东西,只是你已经知道的东西。代码遍历数组,并为每个条目绘制一条新的垂直线。我们确保通过将 shape-rendering
设置为 crispEdges
来避免任何抗锯齿。
是时候定义并激活我们线条组的拖动行为:
var drag = d3.behavior.drag()
.origin(Object)
.on('drag', function () {
d3.select(this)
.attr('transform', 'translate('+d3.event.x+', 0)')
.datum({x: d3.event.x, y: 0});
});
我们使用 d3.behavior.drag()
创建了行为,定义了 .origin()
访问器,并指定了拖动时发生的事情。该行为自动将触摸和鼠标事件转换为更高层次的拖动事件。这有多酷!
我们需要给行为一个原点,这样它就知道如何计算相对位置;否则,当前位置总是设置为鼠标光标,对象会四处跳跃。这太糟糕了。"Object" 是元素的恒等函数,并假设具有 x 和 y 坐标的 datum
。
重的劳动发生在 drag
监听器内部。我们从 d3.event.x
获取屏幕的新位置,将其移动到那里,并更新附加的 .datum()
方法。
剩下的只是调用 drag
并确保将附加的 datum
设置为当前位置:
svg.select('g')
.datum({x: 0, y: 0})
.call(drag);
现在项目看起来很坚固了!尝试以不同的速度拖动屏幕。
在视网膜显示屏上,视差效果并不很好,因为基础图像会被重新调整大小,我们的屏幕失去了校准。
缩放
尽管名字叫缩放行为,但它不仅能缩放,还能平移!就像拖动行为一样,缩放自动处理鼠标和触摸事件,然后触发高级缩放事件。如果你问我,这真的很酷!
记得第三章,使数据有用中的那个地图吗?那个在世界地图上有机场的地图?就是那个。
让我们犯一个计算效率上的错误,让它可以缩放和拖动。
我要警告你,这将会非常基础且痛苦缓慢。这并不是制作一个真正的可探索地图的方法,而只是一个让我们可以尝试缩放的例子。在现实生活中,你应该使用瓦片、渐进式细节和其他技巧。
为了使这个过程稍微容易忍受一些,你应该禁用水域和城市区域。在 JavaScript 代码的大约第 30、36 和 42 行处注释掉 add_to_map
对河流、湖泊和海洋的调用。
你的地图变得简单多了:
最大的效果来自于移除大面积区域,所以如果你也移除了陆地,地图将会有惊人的性能,但相当无用。
跳转到 draw_airlines
的末尾,并添加对 zoomable
的调用;我们将在下一部分定义它:
zoomable(airports, R, routes);
zoomable
需要 airports
、R_scale
和 routes
数据来在缩放时调整圆的大小:
function zoomable(airports, R_scale, routes) {
svg.call(
d3.behavior.zoom()
.translate(projection.translate())
.scale(projection.scale())
.on('zoom', function () {
onzoom(airports, R_scale, routes);
})
);
}
我们使用 d3.behavior.zoom()
定义了一个缩放行为,并立即在整个图像上调用它。
我们将当前的 .translate()
向量和 .scale()
设置为投影所使用的值。缩放事件将调用我们的 onzoom
函数。
让我们定义一下:
function onzoom(airports, R_scale, routes) {
projection
.translate(d3.event.translate)
.scale(d3.event.scale);
d3.selectAll('path')
.attr('d', d3.geo.path().projection(projection));
首先,我们告诉我们的投影新的平移向量是 d3.event.translate
。平移向量将通过转换平移地图,就像在第二章,DOM、SVG 和 CSS 入门中一样。d3.event.scale
是投影用来缩放自身的数字,实际上就是缩放地图。
然后,我们使用新的投影重新计算了所有路径的 d3.geo.path()
。
d3.selectAll('circle')
.attr('transform', function (id) {
var airport = airports[id];
return "translate("+projection([airport.lon, airport.lat])+")";
})
.attr('r', function (id) {
if (routes[id]) {
var magnifier = d3.event.scale/1200;
return magnifier*R_scale(routes[id].length);
}else{
return 1;
}
});
}
同样的方法也适用于圆形。获取新的信息,选择所有的圆形,并更改它们的属性。
定位函数与draw_airlines
中的完全相同,因为地理投影处理了超出框的平移。调整大小需要更多的工作。
在计算放大镜作为当前和默认比例(1200
)之间的比率后,我们使用R_scale
获取圆的正常大小,并将其乘以放大镜。
你现在可以探索这个世界了!
但是要有耐心,因为它很慢。每次移动都重新绘制一切会这样。
为了创建一个更高效的可缩放地图,当缩小视图时,我们必须使用更少细节的数据,绘制一个合理的机场数量,并可能避免绘制图像之外的地图部分。
画笔
与缩放和拖动类似,画笔是一种创建复杂行为的简单方法——它们使用户能够选择画布的一部分。
很奇怪,它们不被视为一种行为,而是属于.svg
命名空间,可能是因为它们主要用于视觉效果。
要创建一个新的画笔,我们会调用d3.svg.brush()
并使用.x()
和.y()
定义其 x 和 y 比例。我们还可以定义一个边界矩形。
是时候举一个例子了!
我们将制作一些随机数据的散点图,并让用户选择点。从绘图区域和一些数据开始:
var width = 600,
height = 600,
svg = d3.select('#graph')
.append('svg')
.attr({width: width,
height: height});
var random = d3.random.normal(.5, .11),
data = d3.range(800).map(function (i) {
return {x: random(),
y: random()};
});
我们使用内置的随机生成器创建围绕.5
中心且分散度为.11
的normal
分布的数字。d3.js 还提供了logNormal
和irwinHall
分布。
现在我们有一个包含 800 个随机二维位置的数组。为了绘制它们,我们将使用两个比例使小范围更明显,然后将每个数据点放置在页面上作为一个圆。
var x = d3.scale.linear()
.range([50, width-50]),
y = d3.scale.linear()
.range([height-50, 50]);
svg.append('g')
.classed('circles', true)
.selectAll('circle')
.data(data)
.enter()
.append('circle')
.attr({cx: function (d) { return x(d.x); },
cy: function (d) { return y(d.y); },
r: 4});
我知道我们在这本书中通常不会添加坐标轴,但如果没有它们,散点图看起来会很荒谬。让我们添加一些:
svg.append('g')
.classed('axis', true)
.attr('transform', 'translate(50, 0)')
.call(d3.svg.axis().orient('left').scale(y));
svg.append('g')
.classed('axis', true)
.attr('transform', 'translate(0, '+(height-50)+')')
.call(d3.svg.axis().orient('bottom').scale(x));
你应该记得这里发生的事情来自第二章,DOM、SVG 和 CSS 入门,在那里我们详细讨论了坐标轴。
为 HTML 添加一些基本样式:
<style>
.axis path,
.axis line {
fill: none;
stroke: black;
shape-rendering: crispEdges;
}
</style>
哈哈,散点图!
现在是时候做一些有趣的事情了:
svg.append("g")
.classed("brush", true)
.call(d3.svg.brush().x(x).y(y)
.on("brushstart", brushstart)
.on("brush", brushmove)
.on("brushend", brushend));
我们为画笔创建了一个新的分组元素,并使用已定义的两个比例调用了一个新构建的d3.svg.brush()
。"brush"
类将有助于样式。最后,我们为brushstart
、brush
和brushend
事件定义了监听器。
function brushstart() {
svg.select('.circles')
.classed('selecting', true);
}
brushstart
将样式切换到selecting
。我们将使用它来帮助用户区分已选择和未选择的圆圈:
function brushmove() {
var e = d3.event.target.extent();
svg.selectAll('circle')
.classed("selected", function(d) {
return e[0][0] <= d.x && d.x <= e[1][0]
&& e[0][1] <= d.y && d.y <= e[1][1];
});
}
brushmove
是真正魔法发生的地方。
首先,我们使用d3.event.target.extent()
找到选择范围的边界。d3.event.target
返回当前的画笔,而.extent()
返回一组两个点——左上角和右下角。
然后,我们遍历所有圆圈,根据圆的位置是否在边界框内,打开或关闭selected
类:
function brushend() {
svg.select('.circles')
.classed('selecting', !d3.event.target.empty());
}
如果选择为空,brushend
只是关闭选择状态。
我们的 HTML 需要更多的样式定义:
.brush .extent {
stroke: #fff;
fill-opacity: .125;
shape-rendering: crispEdges;
}
circle {
-webkit-transition: fill-opacity 125ms ease-in-out;
}
.selecting circle {
fill-opacity: 0.25;
}
circle.selected {
stroke: red;
}
我们正在改变圆形填充的透明度(fill-opacity
),而不是边框的透明度,这样圆形的边缘总是以全透明度闪耀。添加 CSS 过渡使一切感觉更加平滑。
在这种情况下,我们更喜欢 CSS 过渡而不是 d3.js 能做的,这样我们可以将 JavaScript 限制在改变元素状态上。刷子有时也会在 d3.js 过渡中遇到问题,并立即更改属性。
当你选择一些元素时,图像将看起来像这样:
摘要
哇,多么有趣的一章!
你让事物在页面上跳跃,几乎用可缩放的地图杀死了你的电脑和耐心,并且仅用垂直线条就创造了一个旋转的东西。
这就是用户可以与之互动的视觉化所需的一切。其余的只是实验和一些巧妙地将事物组合在一起。祝你好运!
第五章。布局 – d3 的黑色魔法
我们中的大多数人都在互联网上寻找灵感和代码示例。你找到一些看起来很棒的东西,看看代码,然后你的眼睛就会变得模糊。它没有任何意义。
常见的罪魁祸首是 d3 对任何稍微复杂的事情都依赖于布局。从一些数据调用一个函数,然后 voilà——可视化!这种优雅使布局看起来欺骗性地困难,但当你掌握了它们,它们会使事情变得容易得多。
在本章中,我们将带着我们迄今为止所学的一切,全速前进,创建同一数据集的 11 个可视化。
布局是什么以及为什么你应该关心
d3 布局是模块,将数据转换为绘图规则。最简单的布局可能只是将对象数组转换为坐标,就像一个比例尺。
但我们通常使用布局来创建更复杂的可视化——例如绘制一个力导向图或一棵树。在这些情况下,布局帮助我们分离计算坐标和将像素放置在页面上的过程。这不仅使我们的代码更干净,而且还可以让我们为截然不同的可视化重用相同的布局。
理论是无聊的,让我们深入挖掘。
内置布局
默认情况下,d3 附带 12 个内置布局,涵盖了大多数常见的可视化。它们可以大致分为普通布局和层次布局。普通布局如下:
-
histogram
-
pie
-
stack
-
chord
-
force
层次布局如下:
-
partition
-
tree
-
cluster
-
pack
-
treemap
为了了解它们的行为,我们将为每种类型创建一个示例。我们将从谦逊的饼图和直方图开始,然后过渡到力导向图和花哨的树。我们使用相同的数据集来展示所有示例,这样我们就可以感受到不同的展示方式如何影响数据的感知。
这些是本书中的最后几个示例,所以我们将使它们特别出色。这将创建大量的代码,所以每次我们想到可重用的东西,我们都会将其作为一个函数放入helpers.js
文件中。
让我们创建一个空的helper.js
文件:
window.helpers = {
};
我们将把函数作为全局对象的成员添加。在包含正常代码之前,将以下行添加到 HTML 中。
<script src="img/helpers.js"></script>
让我们同意所有示例都以绘图区域和数据获取开始。
var width = 1024,
height = 1024,
svg = d3.select('#graph')
.append('svg')
.attr({width: width,
height: height});
d3.json('data/karma_matrix.json', function (data) {
});
示例代码将放在d3.json
加载监听器中。
数据集
我们将要玩的数据集是从我最喜欢的 IRC 频道的日志中抓取的,追溯到 2011 年末。该频道的特殊功能是 karma 机器人。
当有人做我们喜欢的事情时,我们用nick++
给他们 karma,机器人将其计为对该人的投票。就像在 Reddit 上一样,karma 本来是用来衡量社区对某人的喜爱程度的,但实际上它只是关于谁最活跃。
我们感兴趣的是 karma。
您可以在 raw.github.com/Swizec/d3.js-book-examples/master/ch5/data/karma_matrix.json
获取数据集。该数据集由表示给予 karma 实例的对象组成。每个对象看起来像以下代码:
{"to": "smotko",
"from": "Swizec",
"time": "2012-02-28 23:44:40"}
每个对象都告诉我们某人在何时(time
)向某人(from
)给予 karma。为了处理通常附加到昵称上的杂项——例如,smotko
是来自他手机的 smotko-nexus
——在抓取数据集时只考虑昵称的前四个字母。
这为我们创建了一个干净的数据集来工作。您可以将其视为图中边的一个列表,其中用户是节点,to
和 from
创建一个有向边。
是时候绘制了!
使用直方图布局
我们将使用 histogram
布局来创建人们收到的 karma 的条形图。布局本身将处理从收集值到区间,再到计算条形的高度、宽度和位置的所有事情。
直方图通常表示连续数值域上的概率分布,但昵称是序数的。为了使 histogram
布局符合我们的意愿,我们必须将昵称转换为数字——我们将使用一个刻度。
由于感觉这可能在其他示例中很有用,我们将代码放入 helpers.js
:
uniques: function (data, nick) {
var uniques = [];
data.forEach(function (d) {
if (uniques.indexOf(nick(d)) < 0) {
uniques.push(nick(d));
}
});
return uniques;
},
nick_id: function (data, nick) {
var uniques = helpers.uniques(data, nick);
return d3.scale.ordinal()
.domain(uniques)
.range(d3.range(uniques.length));
},
这些是两个简单的函数。uniques
遍历数据并返回一个唯一昵称的列表。我们通过 nick
访问器帮助它。nick_id
创建一个序数刻度,我们将使用它将昵称转换为数字。
现在我们可以告诉直方图如何使用 nick_id
处理我们的数据。
var nick_id = helpers.nick_id(data, function (d) { return d.to; });
var histogram = d3.layout.histogram()
.bins(nick_id.range())
.value(function (d) { return nick_id(d.to); })(data);
使用 d3.layout.histogram()
我们创建一个新的直方图,并使用 .bins()
定义每个区间的上限。给定 [1,2,3]
,小于 1
的值进入第一个区间,介于 1
和 2
之间的值进入第二个区间,依此类推。
.value()
访问器告诉直方图如何在我们的数据集中查找值。
另一种指定区间的方法是指定所需的区间数量,并让直方图将连续的数值输入域均匀地划分为区间。对于此类域,您甚至可以通过将 .frequency()
设置为 false
来创建概率直方图。您可以使用 .range()
限制考虑的区间的范围。
最后,我们使用布局作为我们数据上的一个函数来获取一个类似这样的对象数组:
{0: {from: "HairyFotr",
time: "2011-10-11 18:38:17",
to: "notepad"},
1: {from: "HairyFotr",
time: "2012-01-09 10:41:53",
to: "notepad"},
dx: 1,
x: 0,
y: 2}
区间宽度在 dx
属性中,x
是水平位置,y
是高度。我们使用常规数组函数访问区间中的元素。
现在用这些数据绘制条形图应该很容易了。我们将为每个维度定义一个刻度,标注两个轴,并放置一些矩形作为条形。
为了使事情更简单,我们首先设置一些边距。记住,所有这些代码都放在我们之前定义的数据加载监听器中:
var margins = {top: 10,
right: 40,
bottom: 100,
left: 50};
并且两个刻度。
var x = d3.scale.linear()
.domain([0, d3.max(histogram, function (d) { return d.x; })])
.range([margins.left, width-margins.right]),
y = d3.scale.log()
.domain([1, d3.max(histogram, function (d) { return d.y; })])
.range([height-margins.bottom, margins.top]);
使用垂直轴的对数刻度将使图表更容易阅读,尽管 karma 变化很大。
接下来,在左侧放置一个垂直轴:
var yAxis = d3.svg.axis()
.scale(y)
.tickFormat(d3.format('f'))
.orient('left');
svg.append('g')
.classed('axis', true)
.attr('transform', 'translate(50, 0)')
.call(yAxis);
我们为每个条形及其标签创建一个分组元素:
var bar = svg.selectAll('.bar')
.data(histogram)
.enter()
.append('g')
.classed('bar', true)
.attr('transform',
function (d) { return 'translate('+x(d.x)+', '+y(d.y)+')'; });
将分组移动到位置,如下面的代码所示,意味着在定位条形及其标签时工作量更少:
bar.append('rect')
.attr({x: 1,
width: x(histogram[0].dx)-margins.left-1,
height: function (d) { return height-margins.bottom-y(d.y); }
});
因为分组已经到位,我们可以将条形图放置在分组边缘的一个像素处。所有条形图的宽度将是histogram[0].dx
,我们将使用每个数据点的y
位置和总图高度来计算高度。最后,我们创建标签:
bar.append('text')
.text(function (d) { return d[0].to; })
.attr({transform: function (d) {
var bar_height = height-margins.bottom-y(d.y);
return 'translate(0, '+(bar_height+7)+') rotate(60)'; }
});
我们将标签移动到图的底部,旋转 60 度以避免重叠,并将它们的文本设置为数据点的.to
属性。
为 HTML 添加一些 CSS 样式:
<style>
.axis path, .axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
.axis text {
font-size: 0.75em;
}
rect {
fill: steelblue;
shape-rendering: crispEdges;
}
</style>
我们的可视化条形图看起来像这样:
好吧,整个图表放不进这本书里。运行示例。
美味的饼图
之前的条形图显示HairyFotr的 karma 最多。让我们找出是谁让他如此受欢迎。
我们将使用饼图布局将HairyFotr的 karma 分割成块,显示他从其他人那里得到多少 karma。在过滤了要给HairyFotr的 karma 数据集之后,我们必须按捐赠者对条目进行分类,最后将它们输入饼图布局以生成饼图。
我们可以使用histogram
布局根据.from
属性将数据放入桶中。让我们在helpers.js
中添加一个函数:
bin_per_nick: function (data, nick) {
var nick_id = helpers.nick_id(data, nick);
var histogram = d3.layout.histogram()
.bins(nick_id.range())
.value(function (d) { return nick_id(nick(d)); });
histogram(data);
return histogram;
},
与uniques
和nick_id
函数类似,bin_per_nick
接受数据和nick
访问器,并返回直方图数据。
我们现在可以在饼图的监听器中这样做:
filtered = data.filter(
function (d) { return d.to == 'HairyFotr'; });
var per_nick = helpers.bin_per_nick(filtered,
function (d) { return d.from; });
per_nick
变量中的条目将告诉我们HairyFotr从某人那里得到了多少 karma。
要制作一个饼图,我们调用pie
布局并提供一个值访问器:
var pie = d3.layout.pie()
.value(function (d) { return d.length; })(per_nick);
pie
布局现在充满了切片对象,每个对象都持有startAngle
和endAngle
值以及原始值。
条目看起来像这样:
{data: Array[135],
endAngle: 2.718685950221936,
startAngle: 0,
value: 135}
我们可以指定一个.sort()
函数来改变切片的组织方式,或者一个.startAngle()
或.endAngle()
函数来限制饼图的大小。
现在剩下的只是绘制饼图。我们需要一个arc
生成器,就像第二章中提到的那些,DOM、SVG 和 CSS 简介,以及一些颜色来区分切片。
找到 24 种看起来很棒且不同的颜色很难;幸运的是,@ponywithhiccups
接受了挑战并做出了选择。谢谢!
让我们在helpers.js
中添加这些颜色:
color: d3.scale.ordinal()
.range(['#EF3B39', '#FFCD05', '#69C9CA', '#666699', '#CC3366', '#0099CC',
'#CCCB31', '#009966', '#C1272D', '#F79420', '#445CA9', '#999999',
'#402312', '#272361', '#A67C52', '#016735', '#F1AAAF', '#FBF5A2',
'#A0E6DA', '#C9A8E2', '#F190AC', '#7BD2EA', '#DBD6B6', '#6FE4D0']),
color
刻度是一个没有域的顺序刻度。为了确保昵称总是得到相同的颜色,helpers.js
中的一个函数将帮助我们固定域,如下面的代码所示:
fixate_colors: function (data) {
helpers.color.domain(helpers.uniques(data,
function (d) { return d.from; }));
}
现在,我们可以定义arc
生成器并固定颜色:
var arc = d3.svg.arc()
.outerRadius(150)
.startAngle(function (d) { return d.startAngle; })
.endAngle(function (d) { return d.endAngle; });
helpers.fixate_colors(data);
每个弧形及其标签都由一个分组元素持有,如下面的代码所示:
var slice = svg.selectAll('.slice')
.data(pie)
.enter()
.append('g')
.attr('transform', 'translate(300, 300)');
为了简化定位,我们将每个分组移动到饼图的中心。创建切片的工作方式与第二章中提到的相同,DOM、SVG 和 CSS 简介:
slice.append('path')
.attr({d: arc,
fill: function (d) { return colors(d.data[0].from); }
});
我们通过d.data[0].from
获取一个切片的颜色——原始数据集在.data
中,其中所有的.from
属性都是相同的。这是我们按其分组的。
标签需要做更多的工作。它们需要旋转到合适的位置,有时还需要翻转,以免出现颠倒的情况。
在以后标记弧线也会很有用,所以让我们在helpers.js
中创建一个通用函数:
arc_labels: function (text, radius) {
return function (selection) {
selection.append('text')
.text(text)
.attr('text-anchor', function (d) {
return helpers.tickAngle(d) > 100 ? 'end' : 'start';
})
.attr('transform', function (d) {
var degrees = helpers.tickAngle(d);
var turn = 'rotate('+degrees+') translate('+(radius(d)+10)+', 0)';
if (degrees > 100) {
turn += 'rotate(180)';
}
return turn;
});
}
},
我们使用部分应用来生成一个在 d3 选择上操作的功能。这意味着我们可以使用它与.call()
一起,同时仍然定义我们自己的参数。
我们将为arc_labels
提供一个text
访问器和radius
访问器,它将返回一个我们可以使用.call()
在选择上调用的函数,以便在正确的位置显示标签。主要部分附加一个文本元素,根据是否需要翻转调整其text-anchor
元素,并使用tickAngle
函数将元素旋转到特定的位置。
让我们添加tickAngle
函数的内容:
tickAngle: function (d) {
var midAngle = (d.endAngle-d.startAngle)/2,
degrees = (midAngle+d.startAngle)/Math.PI*180-90;
return degrees;
}
helpers.tickAngle
计算d.startAngle
和d.endAngle
之间的中间角度,并将结果从弧度转换为度,以便 SVG 可以理解它。
这只是基本的三角学,所以我就不详细说明了,但你的高中好友应该能够解释这个数学。
我们在加载监听器中再次使用arc_labels
:
slice.call(helpers.arc_labels(
function (d) { return d.data[0].from; },
arc.outerRadius()));
如以下截图所示,我们的美味饼图已经完成:
显然,最小的值可以在其他值下进行分组,但你可以自己尝试一下。
通过堆叠显示随时间推移的流行度
D3 的官方文档说:
“堆叠布局接受一个二维数组的数据,并计算一个基线;然后,基线被传播到上面的层,以产生一个堆叠图。”
完全不清楚,但我实在想不出更好的方法。stack
布局计算一个层结束和另一个层开始的位置。一个例子应该会有帮助。
我们将制作一个从 2011 年开始的 karma 层次时间线,每个层的宽度告诉我们用户在特定时间有多少 karma。这个时间线被称为流图。
为了标记层级,我们将创建一个mouseover
行为,该行为会突出显示一个层级并显示带有用户昵称的工具提示。通过调整直到图表看起来很漂亮,我发现我们应该将数据分入 12 天的时段。
让我们开始分桶:
var time = d3.time.format('%Y-%m-%d %H:%M:%S'),
extent = d3.extent(data.map(function (d) { return time.parse(d.time); })),
time_bins = d3.time.days(extent[0], extent[1], 12);
为了将时间戳解析为日期对象,我们指定了类似2012-01-25 15:32:15
的字符串格式。然后,我们使用这个格式通过d3.extent
找到最早和最晚的时间。告诉d3.time.days()
从开始到结束,以 12 天为步长创建一系列的桶。
我们使用histogram
布局将我们的数据集转换成更有用的形式:
var per_nick = helpers.bin_per_nick(data, function (d) { return d.to; });
var time_binned = per_nick.map(function (nick_layer) {
return {to: nick_layer[0].to,
values: d3.layout.histogram()
.bins(time_bins)
.value(function (d) {
return time.parse(d.time); })(nick_layer)};
});
你已经知道helpers.bin_per_nick
的作用。
为了将数据分入时间槽,我们遍历了nick
访问器的每一层,并将其转换为一个具有两个属性的对象。.to
属性告诉我们哪一层代表谁,.values
是时间槽的直方图,条目告诉我们用户在某个 12 天期间获得了多少 karma。
是时候使用stack
布局了:
var layers = d3.layout.stack()
.order('inside-out')
.offset('wiggle')
.values(function (d) { return d.values; })(time_binned);
d3.layout.stack()
创建了一个新的stack
布局。我们告诉它如何使用.order('inside-out')
(你也应该尝试default
和reverse
)来排序层,并决定最终图形的样式使用.offset('wiggle')
。wiggle
最小化了斜率的变化。其他选项包括silhouette
、zero
和expand
。试试它们。
再次,我们告诉布局如何使用.values()
访问器找到值。
我们现在layers
数组中填充了如下对象:
{to: "notepad",
values: Array[50]}
values
是一个数组的数组。外部数组中的条目是时间桶,看起来像这样:
{dx: 1036800000,
length: 1,
x: Object(Thu Oct 13 2011 00:00:00 GMT+0200 (CEST)),
y: 1,
y0: 140.16810522517937}
这个数组的重要部分如下:
x
是水平位置,y
是厚度,y0
是基线。d3.layout.stack
总是会返回这些。
为了开始绘制,我们需要一些边距和两个比例尺:
var margins = {
top: 220,
right: 50,
bottom: 0,
left: 50
};
var x = d3.time.scale()
.domain(extent)
.range([margins.left, width-margins.right]),
y = d3.scale.linear()
.domain([0, d3.max(layers, function (layer) {
return d3.max(layer.values, function (d) {
return d.y0+d.y;
});
})])
.range([height-margins.top, 0]);
难办的是找到垂直比例的域。我们通过遍历每一层的每个值,寻找最大d.y0+d.y
值——基线加上厚度。
我们将为层使用一个area
路径生成器;
var offset = 100,
area = d3.svg.area()
.x(function(d) { return x(d.x); })
.y0(function(d) { return y(d.y0)+offset; })
.y1(function(d) { return y(d.y0 + d.y)+offset; });
没有什么太复杂的,基线定义了底部边缘,加上厚度给出了顶部边缘。调整确定两者都应该向下推 100 像素。
首先让我们画一个轴:
var xAxis = d3.svg.axis()
.scale(x)
.tickFormat(d3.time.format('%b %Y'))
.ticks(d3.time.months, 2)
.orient('bottom');
svg.append('g')
.attr('transform', 'translate(0, '+(height-100)+')')
.classed('axis', true)
.call(xAxis);
和往常一样——我们定义了一个轴,在一个选择上调用它,并让 d3 做它的事情。我们只通过自定义的.tickFormat()
函数让它更漂亮,并使用.ticks()
表示我们想要每两个月有一个新的刻度。
好的,现在对于流图,添加以下代码:
svg.selectAll('path')
.data(layers)
.enter()
.append('path')
.attr('d', function (d) { return area(d.values); })
.style('fill', function (d, i) { return helpers.color(i); })
.call(helpers.tooltip(function (d) { return d.nick; });
没有什么特别的。我们使用了area
生成器来绘制每一层,使用helpers.color
定义了颜色,并调用了一个tooltip
函数,我们将在helpers.js
中稍后定义。
图形看起来像这样:
它看起来很漂亮,但毫无用处。让我们将那个tooltip
函数添加到helpers.js
中:
tooltip: function (text) {
return function (selection) {
selection.on('mouseover.tooltip', mouseover)
.on('mousemove.tooltip', mousemove)
.on('mouseout.tooltip', mouseout);
}
}
我们使用.tooltip
命名空间定义了事件监听器,这样我们就可以在相同的事件上定义多个监听器。
mouseover
函数将突出显示流并创建工具提示,mousemove
将移动工具提示,mouseout
将一切恢复原状。
让我们把三个监听器放在内部函数中:
function mouseover(d) {
var path = d3.select(this);
path.classed('highlighted', true);
}
这是mouseover
的简单部分。它选择当前区域并将其类更改为highlighted
。这将使其变亮并添加一个红色轮廓。
在同一个函数中,添加主要内容:
var mouse = d3.mouse(svg.node());
var tool = svg.append('g')
.attr({'id': "nicktool",
transform: 'translate('+(mouse[0]+5)+', '+(mouse[1]+10)+')'});
var textNode = tool.append('text')
.text(text(d)).node();
tool.append('rect')
.attr({height: textNode.getBBox().height,
width: textNode.getBBox().width,
transform: 'translate(0, -16)'});
tool.select('text')
.remove();
tool.append('text')
.text(d.nick);
它更长,带有一点魔法,但一点也不可怕!
首先,我们找到鼠标的位置,然后创建一个组元素,并将其放置在鼠标的下方和右侧。我们在组中添加一个文本元素,并在其节点上调用 SVG 的 getBBox()
函数。这给我们文本元素的边界框,并帮助我们确定背景矩形的尺寸。
最后,我们移除文本,因为它被背景覆盖,然后再次添加。我们可能可以通过使用 div 来避免所有这些麻烦,但我想要展示纯 SVG 工具提示。因此,考虑以下代码:
function mousemove () {
var mouse = d3.mouse(svg.node());
d3.select('#nicktool')
.attr('transform', 'translate('+(mouse[0]+15)+', '+(mouse[1]+20)+')');
}
以下代码中的 mousemove
监听器非常简单。它只是找到 #nicktool
元素,并将其移动到跟随光标的位置。
function mouseout () {
var path = d3.select(this);
path.classed('highlighted', false);
d3.select('#nicktool').remove();
}
mouseout
函数选择当前路径,移除其 highlighted
样式,并移除工具提示。
哇!工具提示
非常基础——它们不理解边缘,也不会因为外观而打动人心,但它们能完成任务。让我们给 HTML 添加一些 CSS:
<style>
.axis path, .axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
path.highlighted {
fill-opacity: 0.5;
stroke: red;
stroke-width: 1.5;
}
#nicktool {
font-size: 1.3em;
}
#nicktool rect {
fill: white;
}
</style>
现在我们手中有一个可能很有用的流图。
用弦线突出显示朋友
我们已经看到了人们有多少 karma 以及他们何时获得它,但数据中还有一个隐藏的宝石——连接。我们可以使用 chord
布局来可视化谁是某人的朋友。
我们将绘制一个弦图——用户之间连接的圆形图。弦图常用于遗传学,甚至出现在杂志的封面上(circos.ca/intro/published_images/
)。
我们将有一个外环显示用户释放了多少 karma,以及弦线显示 karma 去向何方。
首先,我们需要一个用于弦图的连接矩阵,然后我们将走熟悉的路径,使用路径生成器并添加元素。矩阵代码将以后有用,所以让我们将其放在 helpers.js
中:
connection_matrix: function (data) {
var nick_id = helpers.nick_id(data, function (d) { return d.from; }),
uniques = nick_id.domain();
var matrix = d3.range(uniques.length).map(function () {
return d3.range(uniques.length).map(function () { return 0; });
});
data.forEach(function (d) {
matrix[nick_id(d.from)][nick_id(d.to)] += 1;
});
return matrix;
}
我们从熟悉的 uniques
列表和 nick_id
缩放开始,然后创建一个零矩阵,并通过数据循环增加单元格中的连接计数。行是 来自谁,列是 给谁——如果第一行的第五个单元格包含 10
,则第一个用户向第五个用户提供了十个 karma。这被称为 邻接矩阵。
在加载监听器中,我们可以这样做:
var uniques = helpers.uniques(data, function (d) { return d.from; }),
matrix = helpers.connection_matrix(data);
我们将需要 uniques
标签和 innerRadius
、outerRadius
变量:
var innerRadius = Math.min(width, height)*0.3,
outerRadius = innerRadius*1.1;
是时候让 chord
布局为我们服务了:
var chord = d3.layout.chord()
.padding(.05)
.sortGroups(d3.descending)
.sortSubgroups(d3.descending)
.sortChords(d3.descending)
.matrix(matrix);
它与其他布局略有不同。chord
布局通过 .matrix()
方法获取数据,不能作为一个函数调用。
我们从 d3.layout.chord()
开始,并在组之间添加一些 .padding()
方法以提高可读性。为了进一步提高可读性,一切都被排序了。.sortGroups
按边缘排序组,.sortSubgroups
在组中排序弦附件,.sortChords
按弦绘制顺序排序,以便较小的弦重叠在较大的弦上。
最后,我们使用 .matrix()
将数据馈入布局:
var diagram = svg.append('g')
.attr('transform', 'translate('+width/2+','+height/2+')');
我们添加一个居中的分组元素,这样我们的所有坐标都将从现在起相对于中心。
绘制图表分为三个步骤——弧、标签和和弦,如下面的代码所示:
var group = diagram.selectAll('.group')
.data(chord.groups)
.enter()
.append('g'),
arc = d3.svg.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius);
group.append('path')
.attr('d', arc)R
.attr('fill', function (d) {
return helpers.color(d.index); });
这创建了外环。我们使用 chord.groups
从布局中获取分组数据,为每个和弦组创建一个新的分组元素,然后添加一个弧。我们使用饼图示例中的 arc_labels
来添加标签:
group.call(helpers.arc_labels(
function (d) { return uniques[d.index]; },
function () { return outerRadius+10; }));
尽管半径是恒定的,但我们必须使用以下代码将其定义为函数,因为我们没有使 arc_labels
足够灵活以适应常数。我们真是自作自受!
diagram.append('g')
.classed('chord', true)
.selectAll('path')
.data(chord.chords)
.enter()
.append('path')
.attr('d', d3.svg.chord().radius(innerRadius))
.attr('fill', function (d, i) { return helpers.color(d.target.index); });
我们从 chord.chords
获取和弦数据,并使用 chord path
生成器来绘制和弦。我们使用 d.target.index
来选择颜色,因为这样图表看起来更好,但和弦颜色并不具有信息性。
我们添加一些 CSS 来使和弦更容易追踪:
<style>
.chord path {
stroke: black;
stroke-width: 0.2;
opacity: 0.6;
}
</style>
我们的图表看起来完美无瑕:
它看起来很漂亮但不够直观。在我们弄清楚之前,我们在 IRC 上争论了好几个小时。
首先,和弦颜色没有任何意义!它们只是让区分和弦变得更简单。此外,这个图表显示了每个人给予的 Karma 量。
从我的弧的大小可以看出,我给出了这个频道所有 Karma 的约 30%。我可能太慷慨了。
接触我的弧和弦的宽度告诉你有多少 Karma 将流向谁。
在每个和弦的另一端,情况完全相同。和弦宽度告诉你该用户给了我多少 Karma。和弦是用户之间的双向连接。
使用力布局绘制
force
布局是非层次布局中最复杂的。它允许你使用物理模拟来绘制复杂的图表——如果你愿意,可以称之为力导向图。你绘制的所有内容都将内置动画。
我们将绘制用户之间连接的图表。每个用户将是一个节点,其大小将与用户的 Karma 相对应。节点之间的链接将告诉我们谁给了谁 Karma。
为了使事情更清晰,我们将添加工具提示并确保将鼠标悬停在节点上时突出显示连接的节点。
让我们开始吧。
就像和弦示例一样,我们从一个连接矩阵开始。我们不会直接将其提供给 force
布局,但我们将使用它来创建它喜欢的数据类型:
var nick_id = helpers.nick_id(data, function (d) { return d.from; }),
uniques = nick_id.domain(),
matrix = helpers.connection_matrix(data);
force
布局期望一个节点和链接的数组。让我们来创建它们:
var nodes = uniques.map(function (nick) {
return {nick: nick};
});
var links = data.map(function (d) {
return {source: nick_id(d.from),
target: nick_id(d.to),
count: matrix[nick_id(d.from)][nick_id(d.to)]};
});
我们定义了我们需要的最基本的东西,布局将计算所有困难的部分。
nodes
告诉我们它们代表谁,links
使用 nodes
数组中的索引将一个 source
对象连接到一个 target
对象——布局将把它们转换为正确的引用,如下面的代码所示。每个链接还包含一个 count
对象,我们将用它来定义其强度。
var force = d3.layout.force()
.nodes(nodes)
.links(links)
.gravity(0.5)
.size([width, height]);
force.start();
我们使用 d3.layout.force()
创建一个新的 force
布局;就像 chord
布局一样,它也不是一个函数。我们通过 .nodes()
和 .links()
提供数据。
重力将图表拉向图像的中心;我们使用.gravity()
定义了其强度。我们告诉force
布局我们图片的大小使用.size()
。
直到调用force.start()
之前,不会发生任何计算,但我们需要结果来定义一些后续的刻度。
还有几个参数可以调整:整体的.friction()
(节点稳定到的最小的.linkDistance()
值),.linkStrength()
用于链接的弹性,以及.charge()
用于节点之间的吸引力。玩一玩它们。
nodes
成员现在看起来是这样的:
{index: 0,
nick: "HairyFotr",
px: 497.0100389553633,
py: 633.2734045531992,
weight: 458,
x: 499.5873097327753,
y: 633.395804766377}
weight
告诉我们有多少链接与这个节点相连,px
和py
是它的前一个位置,而x
和y
是当前的位置。
links
成员要简单得多:
{count: 2
source: Object
target: Object}
source
和target
对象是直接引用到正确节点的。
现在布局已经完成了第一步计算,我们有数据来定义一些刻度;
var weight = d3.scale.linear()
.domain(d3.extent(nodes.map(function (d) { return d.weight; })))
.range([5, 30]),
distance = d3.scale.linear()
.domain(d3.extent(d3.merge(matrix)))
.range([300, 100]),
given = d3.scale.linear()
.range([2, 35]);
我们将使用weight
刻度来调整节点大小,distance
来调整链接长度,以及given
来调整节点以实现突出显示效果:
force.linkDistance(function (d) {
return distance(d.count);
});
force.start();
我们使用.linkDistance()
根据.count
属性动态定义链接长度。为了使更改生效,我们使用force.start()
重新启动布局。
最后!是时候在纸上(或者说屏幕上的像素)画一些东西了:
var link = svg.selectAll("line")
.data(links)
.enter()
.append("line")
.classed('link', true);
链接很简单——遍历链接列表并画一条line
。
为每个节点画一个圆圈,并给它正确的尺寸和颜色。奇怪的nick_
类将帮助我们完成在两个鼠标事件监听器中进行的突出显示:
var node = svg.selectAll("circle")
.data(nodes)
.enter()
.append("circle")
.classed('node', true)
.attr({r: function (d) { return weight(d.weight); },
fill: function (d) { return helpers.color(d.index); },
class: function (d) { return 'nick_'+nick_id(d.nick); }})
.on('mouseover', function (d) {
highlight(d, uniques, given, matrix, nick_id);
})
.on('mouseout', function (d) {
dehighlight(d, weight);
});
我们使用熟悉的helpers.tooltip
函数添加工具提示,而force.drag
将自动使节点可拖动:
node.call(helpers.tooltip(function (d) { return d.nick; }));
node.call(force.drag);
经过所有这些工作后,我们仍然需要在force
布局动画的每一帧更新:
force.on("tick", function() {
link.attr("x1", function(d) { return d.source.x; })
.attr("y1", function(d) { return d.source.y; })
.attr("x2", function(d) { return d.target.x; })
.attr("y2", function(d) { return d.target.y; });
node.attr("cx", function(d) { return d.x; })
.attr("cy", function(d) { return d.y; });
});
在tick
事件上,我们将每个link
端点和node
移动到它们的新位置。很简单。
是时候定义我们之前提到的两个突出显示函数了:
function highlight (d, uniques, given, matrix, nick_id) {
given.domain(d3.extent(matrix[nick_id(d.nick)]));
uniques.map(function (nick) {
var count = matrix[nick_id(d.nick)][nick_id(nick)];
if (nick != d.nick) {
d3.selectAll('circle.nick_'+nick_id(nick))
.classed('unconnected', true)
.transition()
.attr('r', given(count));
}
});
}
highlight
函数将根据我们从鼠标接触的节点获得的 karma 增长所有连接的节点。它首先设置given
对象的域,然后遍历uniques
列表,使用given
刻度调整相应节点的大小,并使用nick_id
找到节点。
当前节点保持不变。
dehighlight
将移除我们造成的所有麻烦:
function mouseout (d, weight) {
d3.selectAll('.node')
.transition()
.attr('r', function (d) { return weight(d.weight); });
}
给 HTML 添加一些样式:
<style>
line {
stroke: lightgrey;
stroke-width: 0.3;
}
#nicktool {
font-size: 1.3em;
}
</style>
哇!我们得到了用户连接的力导向图。
运行这个示例看起来很愚蠢,因为它在稳定下来之前旋转了很多。但一旦稳定,图表看起来就像这样:
如果所有节点都不相连,图表会更有趣,但悬停在较小的节点上会揭示有趣的连接。
我们本应该添加一些代码来打印出高亮节点旁边的名称,但示例已经足够长。让我们说这留作读者的练习。
我们现在将转向层次布局!
层次布局
所有分层布局都是基于一个抽象的分层布局,该布局是为了表示分层数据——数据中的数据中的数据中的数据中的数据……你明白这个意思。
partition
、tree
、cluster
、pack
和treemap
布局的所有常见代码都定义在d3.layout.hierarchy()
中,并且它们都遵循类似的设计模式。布局非常相似,官方文档明显地复制粘贴了大部分的解释。让我们先看看共同的东西,然后我们将专注于差异。
首先,我们需要一些分层数据。我花了一个下午的时间尝试将我们的 karma 数据集分层。结果是,这个方案与三种布局配合得很好,但看起来对另外两种布局有些牵强。对此表示歉意。
实际上很简单,我们杀死了蝙蝠侠。
我们将有一个名为karma
的根节点,它将包含所有曾经给予 karma 的 24 个用户。对于tree
和cluster
布局,每个都将包含他们给予 karma 的每个人的节点。对于partition
、pack
和treemap
布局,子节点将告诉我们谁为父节点的 karma 做出了贡献。
最终的数据结构将看起来像这样:
{
"nick": "karma",
"children": [
{
"nick": "HairyFotr",
"count": 312,
"children": [
{
"nick": "notepad",
"count": 2,
"children": []
},
{
"nick": "LorD_DDooM",
"count": 6,
"children": []
},
虽然它可能永远继续下去,但在我们的情况下这并不合理。
默认访问器期望有一个.children
属性,但我们可以轻松地做一些疯狂的事情,比如在自定义访问器中动态生成一个分形结构。
如同往常,有一个.value()
访问器可以帮助布局在节点中找到数据。我们将使用它来设置.count
属性——以检查用户有多少 karma。
要运行分层布局,我们调用.nodes()
并传入我们的数据集。这立即返回一个列表,你以后无法获取到这些节点。要获取连接列表,我们调用.links()
并传入我们的节点列表。返回列表中的节点将具有布局计算的一些额外属性。大多数布局会告诉我们如何使用.x
和.y
放置某个东西,然后使用.dx
和.dy
来告诉我们布局应该有多大。
所有分层布局都支持使用.sort()
进行排序,它接受一个排序函数,如d3.ascending
或d3.descending
。
理论已经足够多了,让我们向helpers.js
添加一个数据处理函数:
make_tree: function (data, filter1, filter2, nick1, nick2) {
var tree = {nick: 'karma',
children: []};
var uniques = helpers.uniques(data, function (d) { return d.from; });
tree.children = uniques.map(
function (nick) {
var my_karma = data.filter(function (d) { return filter1(d, nick); }).length,
given_to = helpers.bin_per_nick(
data.filter(function (d) { return filter2(d, nick); }),
nick1
);
return {nick: nick,
count: my_karma,
children: given_to.map(function (d) {
return {nick: nick2(d),
count: d.length,
children: []};
})};
});
return tree;
},
哇,这里发生了很多事情。我们避免了递归,因为我们知道我们的数据永远不会嵌套超过两层。
tree
最初持有一个空的根节点。我们使用helpers.uniques
来获取昵称列表,然后遍历数组,通过计算每个人的 karma 值并使用helpers.bin_per_nick
来获取子节点数组,从而定义根节点的子节点。
代码之所以会摇摆不定,是因为我们使用了filter1
、filter2
、nick1
和nick2
作为数据访问器,但使这个函数变得灵活使其在所有分层示例中都很有用。
绘制一棵树
tree
布局使用整洁的 Reingold-Tilford 算法以树的形式显示数据。我们将用它来显示我们的数据集,以一个大圆形树的形式,每个节点通过一条曲线与其父节点相连。
我们从固定颜色开始,将数据转换为树,并定义绘制曲线的方法:
helpers.fixate_colors(data);
var tree = helpers.make_tree(data,
function (d, nick) { return d.to == nick; },
function (d, nick) { return d.from == nick; },
function (d) { return d.to; },
function (d) { return d[0].to; });
var diagonal = d3.svg.diagonal.radial()
.projection(function(d) { return [d.y, d.x / 180 * Math.PI]; });
你之前已经知道 fixate_colors
,我们定义 make_tree
不久前,我们已经在 第二章 中讨论了 diagonal
生成器,DOM、SVG 和 CSS 入门。
var layout = d3.layout.tree()
.size([360, width/2 - 120]);
var nodes = layout.nodes(tree),
links = layout.links(nodes);
我们通过调用 d3.layout.tree()
创建一个新的树布局。使用 .size()
定义其大小,并通过 .nodes()
执行它。size()
告诉布局它有多少空间——在这种情况下,我们使用 x
作为角度(360
度)和 y
作为半径。尽管布局本身并不真正关心这一点。
为了避免以后担心居中对齐的问题,我们放置了一个分组元素作为中心:
var chart = svg.append('g')
.attr('transform', 'translate('+width/2+','+height/2+')');
首先,我们将绘制链接,然后是节点及其标签:
var link = chart.selectAll(".link")
.data(links)
.enter()
.append("path")
.attr("class", "link")
.attr("d", diagonal);
你现在应该熟悉这个了;通过数据并使用 diagonal
生成器添加新的路径:
var node = chart.selectAll(".node")
.data(nodes)
.enter().append("g")
.attr("class", "node")
.attr("transform", function(d) { return "rotate(" + (d.x - 90) + ")translate(" + d.y + ")"; });
对于数据中的每个节点,我们创建一个新的分组元素,并使用 rotate
对角度和 translate
对半径位置进行移动。
现在只需要添加一个圆圈和一个标签:
node.append("circle")
.attr("r", 4.5)
.attr('fill', function (d) { return helpers.color(d.nick); });
node.append("text")
.attr("dy", ".31em")
.attr("text-anchor", function(d) { return d.x < 180 ? "start" : "end"; })
.attr("transform", function(d) { return d.x < 180 ? "translate(8)" : "rotate(180)translate(-8)"; })
.text(function(d) { return d.nick; })
.style('font-size', function (d) { return d.depth > 1 ? '0.8em' : '1.1em'; });
每个节点都使用用户的原生颜色着色,文本的转换方式与早期的饼图和弦图示例类似。最后,我们将叶节点的文本缩小以避免重叠。
然后,我们将添加一些样式:
<style>
.link {
fill: none;
stroke: lightgrey;
}
</style>
我们的树看起来像这样:
它相当大,所以你应该在浏览器中尝试一下。只需记住,内环是用户对外环给予的 karma。
显示集群
cluster
布局与 tree
布局相同,只是叶节点排列整齐。
你看到那个 hoi 用户在树示例的内环中闲逛吗?在集群布局中,他们最终出现在外部,与其他叶节点一起。
代码上,这个例子与上一个相同,所以我们不会再次讲解。实际上,唯一的区别是我们不需要在特定角度翻转标签。你可以在 GitHub 示例存储库中查看代码 github.com/Swizec/d3.js-book-examples/blob/master/ch5/cluster.js
。
我们最终得到一个非常高的图表,看起来像这样:
分割饼图
现在我们正在取得进展!接下来的三个布局完美地适合我们的数据——我们正在从三个角度来看我们的核心用户的 karma 结构。
partition
布局创建邻接图,其中你不需要在节点之间画线,而是将它们并排放置,使其看起来像子节点分割父节点。
我们将绘制一个双层甜甜圈图。用户将在第一层,顶层将显示 karma 的来源。
我们首先对数据集进行整理并固定颜色:
var tree = helpers.make_tree(data,
function (d, nick) { return d.to == nick; },
function (d, nick) { return d.to == nick; },
function (d) { return d.from; },
function (d) { return d[0].from; });
helpers.fixate_colors(data);
然后使用partition
布局:
var partition = d3.layout.partition()
.value(function (d) { return d.count; })
.sort(function (a, b) {
return d3.descending(a.count, b.count);
})
.size([2*Math.PI, 300]);
var nodes = partition.nodes(tree);
我们使用.value()
告诉布局我们关心.count
值,如果我们.sort()
输出,我们会得到更好的图像。同样,对于tree
布局,x
将代表角度——这次是弧度,而y
将是半径。
我们还需要一个arc
生成器,如下面的代码所示:
var arc = d3.svg.arc()
.innerRadius(function (d) { return d.y; })
.outerRadius(function (d) {
return d.depth ? d.y+d.dy/d.depth : 0; });
生成器将使用每个节点的.y
属性作为内半径,并添加.dy
作为外半径。调整后显示外层应该更薄,因此我们将其除以树深度。
注意,没有.startAngle
和.endAngle
的访问器,它们存储为.x
和.dx
。直接固定数据会更简单:
nodes = nodes.map(function (d) {
d.startAngle = d.x;
d.endAngle = d.x+d.dx;
return d;
});
nodes = nodes.filter(function (d) { return d.depth; });
这就像映射数据并定义角度属性,然后过滤数据以确保根节点不被绘制。
我们使用熟悉的分组技巧来居中我们的图表。
var chart = svg.append('g')
.attr('transform', 'translate('+width/2+','+height/2+')');
准备工作已完成。现在是绘图时间:
var node = chart.selectAll('g')
.data(nodes)
.enter()
.append('g');
node.append('path')
.attr({d: arc,
fill: function (d) { return helpers.color(d.nick); }});
为每个节点绘制一个弧,颜色选择如常:
node.filter(function (d) { return d.depth > 1 && d.count > 10; })
.call(helpers.arc_labels(function (d) { return d.nick; },
arc.outerRadius()));
node.call(helpers.tooltip(function (d) { return d.nick; }));
我们使用之前准备的函数添加标签和工具提示。我们避免为非常薄的切片添加标签,以免它们重叠并造成混乱。添加一些 CSS:
<style>
path {
stroke: white;
stroke-width: 2;
}
#nicktool {
font-size: 1.3em;
}
#nicktool rect {
fill: white;
}
</style>
邻接图看起来是这样的:
将圆圈打包成圆圈
pack
布局使用打包来直观地表示层次结构。它将子节点塞入父节点中,试图节省空间并为每个节点指定大小,使其成为其子节点的累积大小。
从概念上讲,它与treemap
布局非常相似,所以我将跳过所有代码,只展示图片。你仍然可以在 GitHub 上看到代码github.com/Swizec/d3.js-book-examples/blob/master/ch5/pack.js
。
备注
代码相当熟悉——生成树,固定颜色,创建布局,调整一些参数,获取计算后的节点,绘制节点,并添加工具提示。很简单。
它看起来非常漂亮,但不太具有信息量。添加标签帮助不大,因为大多数节点太小。
使用 treemap 细分
treemap
布局通过水平和垂直切片来细分节点,本质上就像pack
布局一样将子节点打包到父节点中,但使用矩形。因此,每一层的节点大小可以直接比较,这使得它成为分析细分累积效应的最佳布局之一。
我们将在这个例子中玩得开心。工具提示将命名父节点——父节点几乎完全被子节点遮挡——将鼠标悬停在节点上会使无关的节点变亮,使图表更不容易混淆(至少在理论上)。
这也是一个很酷的效果,也是结束这一章布局的绝佳方式。
我们从无聊的事情开始;准备数据和固定颜色:
var tree = helpers.make_tree(data,
function (d, nick) { return d.to == nick; },
function (d, nick) { return d.to == nick; },
function (d) { return d.from; },
function (d) { return d[0].from; });
helpers.fixate_colors(data);
创建treemap
布局遵循熟悉的模式:
var treemap = d3.layout.treemap()
.size([width, height])
.padding(3)
.value(function (d) { return d.count; })
.sort(d3.ascending);
var nodes = treemap.nodes(tree)
.filter(function (d) { return d.depth; });
我们使用 .padding()
添加了一些填充,给节点留出呼吸的空间。
每个节点都将成为一个包含矩形的组元素。叶子节点也将包含标签:
var node = svg.selectAll('g')
.data(nodes)
.enter()
.append('g')
.classed('node', true)
.attr('transform', function (d) { return 'translate( node.a'+d.x+','+d.y+')'; });
ppend('rect')
.attr({width: function (d) { return d.dx; },
height: function (d) { return d.dy; },
fill: function (d) { return helpers.color(d.nick); }});
现在是第一个有趣的部分。让我们将标签尽可能放入尽可能多的节点中:
var leaves = node.filter(function (d) { return d.depth > 1; });
leaves.append('text')
.text(function (d) { return d.nick; })
.attr('text-anchor', 'middle')
.attr('transform', function (d) {
var box = this.getBBox(),
transform = 'translate('+(d.dx/2)+','+(d.dy/2+box.height/2)+')';
if (d.dx < box.width && d.dx > box.height && d.dy > box.width) {
transform += 'rotate(-90)';
}else if (d.dx < box.width || d.dy < box.height) {
d3.select(this).remove();
}
return transform;
});
最后!这是一些有趣的代码!
我们找到了所有叶子节点,并开始添加文本。为了将标签放入节点,我们使用 this.getBBox()
获取它们的尺寸,然后将它们移动到节点的中间,并检查是否适合。
如果标签太宽但垂直方向可以容纳,我们会将其旋转;否则,在再次确认它无法容纳后,我们会移除标签。确保高度是很重要的,因为一些节点非常细小。
我们使用 helpers.tooltip
添加工具提示:
leaves.call(helpers.tooltip(function (d) { return d.parent.nick; }));
另一个有趣的部分——部分隐藏来自不同父节点的节点:
leaves.on('mouseover', function (d) {
var belongs_to = d.parent.nick;
svg.selectAll('.node')
.transition()
.style('opacity', function (d) {
if (d.depth > 1 && d.parent.nick != belongs_to) {
return 0.3;
}
if (d.depth == 1 && d.nick != belongs_to) {
return 0.3;
}
return 1;
});
})
.on('mouseout', function () {
d3.selectAll('.node')
.transition()
.style('opacity', 1);
});
我们使用了两个鼠标事件监听器:一个创建效果,另一个移除效果。mouseover
监听器遍历所有节点,使具有不同父节点或不是父节点的节点变亮(d.parent.nick
和 d.nick
不同)。mouseout
监听器移除所有更改。
然后,添加一些 CSS:
<style>
#nicktool {
font-size: 1.3em;
}
#nicktool rect {
fill: white;
}
.node text {
font-size: 0.9em;
}
.name text {
font-size: 1.5em;
}
.name rect {
fill: white;
}
</style>
最终结果看起来像一幅抽象画:
使用鼠标点击区域可以恢复一些理智,如下面的截图所示:
尽管如此,并没有我们期望的那么理智。
摘要
尽管 d3 布局具有近乎神话般的力量,但它们实际上只是将你的数据转换成坐标集合的辅助工具。
在这些示例中全力以赴之后,我们几乎用到了迄今为止学到的所有技巧。我们甚至写了这么多代码,以至于不得不创建一个单独的库!通过一些泛化,其中一些函数可以成为自己的布局。对于各种类型的图表,有一个完整的社区开发的布局世界。GitHub 上的 d3-plugins 仓库(github.com/d3/d3-plugins
)是一个开始探索的好方法。
你现在已经了解了所有默认布局的目的,我希望你已经开始考虑将它们用于原始开发者最疯狂的梦想之外的目的。
第六章:设计良好的可视化
一个好的可视化不仅仅是关于用 JavaScript 玩乐。可视化应该用数据讲故事,并且做得很好。你希望用你的美学吸引和吸引观众,用你的故事揭示世界。
完全诚实地说,这一章似乎是最难写的一章。但如果我们不讨论是什么让可视化变得美丽和有效,那么关于制作美丽视觉化的书会是什么样子呢?
我是一个程序员,不是一个设计师,但我已经看过很多可视化,好的和不好的。让我向你展示一些我发现的令人惊叹的例子。
什么是可视化?
可视化只是基于数据的一张图片。每次你将数据转化为图片时,你就创造了一个可视化。
如 19 世纪经典书籍《经济与工业幻想:关于保护案例的讨论》的作者 Farquhar, Arthur B 所说:
“图形方法在展示统计数据方面相对于表格方法具有相当的优势。一大堆数字对眼睛和大众思维来说都是一种折磨,就像从黄瓜中提取阳光一样,人们无法从中得出任何有用的教训。”
但是,旧图表和 d3.js 示例画廊中的视觉杰作之间存在着天壤之别。这种新媒体让我们能够用数据做更多的事情,因此可视化技术的普及增长几乎不足为奇。
与一个好的现代可视化相比,图表仅仅展示了原始数据。它没有告诉你任何东西,但可视化却可以。
你甚至不需要全力以赴。有时候,添加标签就足以将一个无聊的图表变成吸引人的可视化——这是《纽约时报》经常使用的一个技巧。
一个只有两条指数上升线的简单图表:无聊。添加一些标签,它就变成了引人入胜的故事。在图表的左上角放置你所看到的解释,突出图表上的故事点,甚至图例也包含关于故事的一个有趣点。
《纽约时报》的图形编辑 Amanda Cox 称这为注释层。查查她;她关于视觉化的演讲很棒。
与我们迄今为止的视觉化相比。它们很有趣,有很好的震撼效果,但并不擅长讲述故事。它们作为例子和制作不错的艺术作品很棒,但我们经常没有时间正确地标注它们。
更好的是,看看 d3.js 关于堆叠布局的文档中引用的这种可视化。
美丽!你能猜到这显示的是 Flickr 照片中颜色的季节性涨落吗?可能不会。
在设计师的帮助下,作者们在《波士顿》杂志的最终印刷版中改进了这个概念,通过标注圆形时间轴和添加典型照片到图像的关键点。
优秀的可视化似乎在静态图表和数据艺术之间的快乐中找到了平衡。一个好的可视化应该既美丽又富有信息。
一些很好的例子
许多艰苦的学术研究都投入到了可视化设计中。当你寻找特定内容时——比如如何最好地在散点图和直方图之间过渡(vis.berkeley.edu/papers/animated_transitions/
)——阅读一些材料是个好主意,但要掌握这样一个广泛的领域需要时间。
要全面了解可视化方法,请查看 KPI 图书馆开发的周期表。您可以在www.visual-literacy.org/periodic_table/periodic_table.html
找到它。
周期表使用六个类别——数据、信息、概念、策略、隐喻和复合可视化。将鼠标悬停在单元格上会显示一个示例类别。
表本身是一个做得很好的可视化。它可能不美丽或富有艺术性,但完美地模仿了常见的周期表。它立即吸引了我们的注意力,并因为熟悉而留在我们的脑海中,并且很好地展示了数据。另一方面,内容太多,没有重点。与其说是一个故事,不如说是一个“这里有一切;处理它”。
更重要的是,这个周期表是一个位图图像——这是你在网上绝对不应该做的事情,原因从搜索引擎到可用性都有。
暴乱逮捕
《卫报》在 2012 年伦敦暴乱期间对暴乱逮捕的报道是一个简单而强大的可视化示例。
直方图是比较类别大小的明显选择,但你可能会遇到人们看到并不存在的趋势的问题。人们也被迫阅读标签,因为所有条形看起来都一样。
当你想比较子类别或只是显示所有类别的总和时,直方图会进一步分解。
《卫报》使用颜色来区分类别,并使用圆面积来显示这些类别的规模。这之所以有效,是因为经过多年的图表和图表,每个人都知道颜色是类别。甚至在看到数字之前,读者就能认出较大的圆圈是较大的类别。但请注意,使用面积来表示值,而不是直径——直径减半的圆面积只有四分之一那么大!
唯一一眼就能看出的信息是,较小的圆圈是中心大圆的子类别。点击任何一个较小的圆圈就可以快速回答这个问题。
您可以在www.guardian.co.uk/uk/datablog/interactive/2011/dec/06/england-riots-crimes-arrested
找到这个可视化。
《悲惨世界》共现
另一种颜色的大妙用是显示强度。这种方法最常用于矩阵图,其中较暗表示更多,较亮表示更少。这是一个非常自然的效果——空单元格是白色的,你在一个单元格中放入的点越多,它就越暗。用笔试试吧。
迈克·博斯特克在《悲惨世界》图中使用了这种效果,展示了出现在同一章节中的角色。
与图表和树状图不同,矩阵图不会因为许多连接而变得过于繁忙。记住我们的弦图,那些难以阅读的细线丛林?这里不会发生这种情况。
然而,矩阵图对边缘排序很敏感。当边缘使用聚类算法——社区发现——进行排序时,我们得到一幅美丽的画面。
但矩阵图在按字母顺序排序的边缘下看起来非常不同。
它比集群版本要混乱得多,描绘的画面也更糟糕。
矩阵图面临的另一个问题是,在两个节点之间追踪路径几乎是不可能的。明智地选择你的可视化。
您可以在bost.ocks.org/mike/miserables/
找到《悲惨世界》共现矩阵。
《国家财富与健康》
可视化面临的另一个有趣问题是将太多维度压缩到二维介质中。你将如何绘制一个国家人均收入与预期寿命之间的关系?一个简单的折线图,对吧?但是如何融入时间,增加更多国家,人口,以及……为了使事情更有趣,让我们加上地区。这就是五个维度!
Gapminder 的《国家财富与健康》是一个将五个维度压缩到两个维度而不显得拥挤的美丽例子。每个国家在图上都是一个气泡,其中垂直轴表示预期寿命,水平轴是人均收入。时间以动画形式显示,地区用颜色表示,人口用圆面积表示。
也许我对数据过于兴奋,但看着那些点随着时间流逝而舞动并变大真的很有趣。您可以探索整个国家的历史!
您可以看到一个国家财富与其预期寿命之间的直接相关性。此外,随着时间的推移,每个人的预期寿命都在上升,所有国家都在变大。
我尝试的另一个有趣的游戏是确定历史上的重大事件。例如,日本在 1945 年左右财富和健康急剧下降,但很快又恢复了。
你可以在 bost.ocks.org/mike/nations/
找到 Mike Bostock 对该可视化的 d3.js 再现。
更多精彩内容
我们可以一起分析更多例子,但那样有什么乐趣呢?在互联网上找到你自己的灵感。d3.js 的例子画廊有很多宝藏,例如使用圆形直方图展示机场风速历史的可视化 (windhistory.com/map.html#9.00/33.5876/-118.1257
) 或通过在分形模式中分割圆圈绘制的考拉:www.koalastothemax.com/
。
《纽约时报》 列出的 2012 年:图形年鉴 是另一个很好的资源。关于奥运会的可视化是我最喜欢的。它们可以在 www.nytimes.com/interactive/2012/12/30/multimedia/2012-the-year-in-graphics.html
找到。
《卫报》 将他们的可视化列表与数据集一起发布。你可以通过 www.guardian.co.uk/data
访问。
摘要
我们尝试涉足设计领域,观察了一些优秀的例子,并试图了解它们是如何工作的。作为一个非设计师,我就是这样接近任何我想创建的新可视化——查看大量例子,并找出哪些适合我的数据集。
然后就是实验,实验,再实验,直到找到有效的方法。
要深入了解设计可视化,我建议阅读专门介绍这一主题的书籍。《数据可视化:成功的设计流程》,Andy Kirk 和他的博客 Visualizing Data 是一个很好的起点。我还没有读过这本书,但博客在这章中帮助了我很多。
另一个很好的资源是 《可视化这》,Nathan Yau。前几章是关于设计的;其余部分是关于使用 R——一种统计分析语言。阅读 《网络交互式数据可视化》,Scott Murray 也会很有帮助。