D3-js-4-映射学习手册-全-
D3,js 4 映射学习手册(全)
原文:
zh.annas-archive.org/md5/7e97df38e228bedecf88e79ce9143bb4
译者:飞龙
前言
本书探讨了 JavaScript 库 D3.js 及其帮助我们创建地图和令人惊叹的可视化的能力。你将不再受限于第三方工具来获得一个看起来不错的地图。使用 D3.js,你可以构建自己的地图,并按你的意愿进行定制。本书将从 SVG、Canvas 和 JavaScript 的基础知识开始,通过使用 TopoJSON 进行数据修剪和修改,使用 D3.js 将这些关键成分粘合在一起,我们将创建非常吸引人的地图,涵盖许多常见用例,如等值线图、地图上的数据叠加、交互性和性能。
这本书涵盖的内容
第一章,收集你的制图工具箱,从实际案例开始,让你对本书结束时能构建的内容有所了解。
第二章,从简单文本创建图像,深入 SVG 及其常见的地理形状和属性。展示了如何使用矢量进行动画。
第三章,从数据生成图形 - D3 的基础,了解 D3 中不同状态的基础以及它与 DOM 的交互。
第四章,创建地图,展示了我们构建地图的第一个例子。本章涵盖了基本事件和扩展到地图边界之外的内容,以及我们将地图与其他数据集交织在一起。
第五章,点击-点击-爆炸!将交互性应用到你的地图上,深入探讨了在浏览器中与地图的所有交互类型。这包括悬停、平移、缩放等。
第六章,寻找和利用地理数据,展示了如何寻找和利用地理空间数据。
第七章,测试,描述了如何构建你的代码库,以便拥有可重用的图表组件,这些组件易于单元测试,并准备好在未来的项目中重用。
第八章,使用 Canvas 和 D3 绘图,展示了如何开始使用 Canvas。你将学习如何绘图、动画化以及使用 D3 的生命周期进行数据更新。
第九章,使用 Canvas 和 D3 进行地图绘制,描述了如何使用 Canvas 绘制和动画化数千个点,以及 Canvas 动画与 SVG 动画的比较。
第十章,将交互性添加到你的 Canvas 地图中,指导你通过添加交互性的过程,这个过程比 SVG 需要更多的思考和关注。
第十一章,使用数据塑造地图 - 六边形地图,解释了如何使用 D3 构建六边形地图 - 一种展示地理空间点数据的好方法。
第十二章,使用 GitHub Pages 发布可视化,展示了如何以简单快捷的方式将你的可视化内容上线。
你需要这本书的内容
以下这本书的要求;这些在 macOS、Windows 和 Linux 上运行:
以及 Linux:
-
D3.js 库 v4.12.0
-
Node.js v8.9.0+
-
例如,npm,v5.5.1+
这本书面向的对象
这本书是为至少具备基本网络开发知识(基本 HTML/CSS/JavaScript)的人准备的。您不需要在之前使用过 D3.js。
Conventions
在这本书中,您将找到许多不同的文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 标签应如下所示:“width
和height
是画布元素拥有的唯一属性。”
代码块设置如下:
context.save();
context.translate(140, 190);
context.fillRect(0, 0, 60, 30);
context.restore();
新术语和重要单词以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“您前往github.com/
,点击登录,并按照步骤操作。”
警告或重要注意事项如下所示。
技巧和窍门如下所示。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢什么或不喜欢什么。读者反馈对我们来说非常重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。
要向我们发送一般性反馈,请简单地发送电子邮件至feedback@packtpub.com
,并在邮件主题中提及书籍的标题。
如果您在某个领域有专业知识,并且您对撰写或参与一本书籍感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经成为 Packt 书籍的骄傲拥有者,我们有一些东西可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您的www.packtpub.com
账户下载此书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support
并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的“支持”标签上。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击“代码下载”。
您也可以通过点击 Packt Publishing 网站上书籍网页上的“代码文件”按钮来下载代码文件。您可以通过在搜索框中输入书籍名称来访问此页面。请注意,您需要登录您的 Packt 账户。
一旦文件下载完成,请确保您使用最新版本的以下软件解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learning-D3js-4-Mapping-Second-Edition
。我们还有来自我们丰富的图书和视频目录的其他代码包,可在github.com/PacktPublishing/
找到。查看它们吧!
下载本书的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。这些彩色图像将帮助您更好地理解输出中的变化。您可以从www.packtpub.com/sites/default/files/downloads/LearningD3dotjs4MappingSecondEdition_ColorImages.pdf
下载此文件。
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata
,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误表部分。
要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support
,并在搜索字段中输入本书的名称。所需信息将出现在勘误表部分。
盗版
互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以追究补救措施。
请通过copyright@packtpub.com
与我们联系,并附上疑似盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
询问
如果您对本书的任何方面有问题,您可以通过questions@packtpub.com
联系我们,我们将尽力解决问题。
第一章:收集您的地图学工具箱
欢迎来到 D3 的地图学世界。在本章中,您将获得创建地图所需的所有工具,这些工具都是免费和开源的,得益于美好的开源世界。鉴于我们将使用网络术语进行讨论,我们的语言将是 HTML、CSS 和 JavaScript。阅读完这本书后,您将能够有效地使用这三种语言来创建自己的地图。
在本章中,我们将涵盖以下主题:
-
快速引导
-
逐步引导
-
安装关键库和工具
-
使用网络浏览器作为开发工具
在 D3 中创建地图时,您的工具箱非常轻便。目标是专注于创建数据可视化,并移除重 IDE 和地图制作软件的负担。
快速引导
以下说明假设 Node.js、npm
和git
已安装到您的系统上。如果没有,请随意遵循逐步引导部分。
在命令行中输入以下内容以安装轻量级网络服务器:
npm install -g http-server
安装 TopoJSON:
npm install -g topojson
克隆包含库的示例代码:
git clone --depth=1 git@github.com:climboid/d3jsMaps.git
进入根项目:
cd d3jsMaps
要启动服务器,请输入以下命令:
http-server
您可以从www.packtpub.com
下载示例代码文件,这些文件存储在您购买的所有 Packt Publishing 书籍的账户中。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support
并注册,以便将文件直接通过电子邮件发送给您。
现在,打开您的网络浏览器到http://localhost:8080/chapter-1/example-1.html
,您应该看到以下地图:
逐步引导
下一节将详细介绍如何设置开发环境,如果您没有安装所需的任何包。到本章结束时,您将拥有本书剩余部分的工作环境(一个运行中的地图示例以及创建可视化所使用的工具的初步了解)。
轻量级网络服务器
从技术上讲,我们将创建的大部分内容可以直接在浏览器中渲染,而无需使用网络服务器。然而,我们强烈建议您不要采取这种方法。在本地开发环境中运行网络服务器非常简单,并提供了一些好处:
-
地理信息、统计数据和可视化代码可以清晰地分离到独立的文件中
-
API 调用可以被模拟和模拟,允许轻松集成到未来的全栈应用程序中
-
它将防止在调用 AJAX 以获取地理和统计数据时出现常见错误(例如,同源策略)
对于我们选择的 Web 服务器和工具箱中的其他工具,我们将依赖于一个名为 http-server
的 Node.js 包。Node.js 是一个基于 Chrome 的 JavaScript 运行时的平台,用于构建快速、可扩展的网络应用程序。该平台包括 Node 包管理器 (npm),它是由充满活力的 Node.js 社区的其他成员创建的,允许开发者快速安装预构建软件的包。
要安装 Node.js,只需执行以下步骤:
-
访问网站
nodejs.org
。 -
点击安装按钮。
-
打开下载的包并遵循默认设置。
要测试安装,请在命令行中输入以下内容:
node -v
应该返回类似以下的内容:
v0.10.26
这意味着我们已经安装了本书编写时的给定版本的 Node.js。
TopoJSON 是一个命令行工具,用于创建 TopoJSON 序列化格式的文件。TopoJSON 格式将在第六章 [80f84fe0-7828-4ecc-9973-3e2bdef7ab55.xhtml] 中详细讨论,寻找和操作地理数据。TopoJSON 工具也是通过 npm
安装的。
我们已经安装了 Node.js 和 npm
,所以请在命令行中输入以下内容:
npm install -g topojson
安装完成后,你应该检查机器上安装的 TopoJSON 版本,就像我们检查 Node.js 一样:
geo2topo --version
如果你看到版本 3.x,这意味着你已成功安装 TopoJSON。
TopoJSON 使用 node-gyp
,它基于操作系统有几个依赖项。请访问 github.com/TooTallNate/node-gyp
获取详细信息。
如果你使用的是 Windows 系统,使 TopoJSON 工作的基本步骤如下:
-
安装 Python 2.x(本书编写时不支持 3.x)。
-
安装桌面版 Microsoft Visual Studio C++ 2012。
使用网页浏览器作为开发工具
虽然任何现代浏览器都支持 可缩放矢量图形 (SVG) 并有一些类型的控制台,但我们强烈建议你使用 Google Chrome 进行这些示例。它捆绑了开发者工具,这将使你能够非常容易地打开、探索和修改代码。如果你没有使用 Google Chrome,请访问 www.google.com/chrome
并安装 Google Chrome。
安装示例代码
访问 github.com/climboid/d3jsMaps
并根据你是否熟悉 Git 克隆,克隆仓库或简单地下载压缩版本。下载完成后,请确保如果你有压缩文件,将其解压。
使用命令提示符或终端转到你下载文件的目录。例如,如果你将文件下载到桌面,请输入以下内容:
cd ~/Desktop/d3jsMaps
要启动服务器,请输入以下内容:
http-server
最后一条命令将启动我们之前安装的用于提供示例代码的简单服务器。这意味着,如果你打开浏览器并转到 http://localhost:8080/chapter-1/example-1.html
,你应该看到一个地图
欧洲的,类似于前面展示的。
使用开发者工具
是时候打开开发者工具了。在浏览器的右上角,你会看到以下截图所示的图标:
此图标打开一个子菜单。点击更多工具,然后点击开发者工具。
浏览器底部将打开一个面板,包含你可用的一切开发者工具。
这里提到的选项名称可能会根据你使用的 Chrome 版本而有所不同。
为了快速访问 Mac 上的开发者工具,使用 alt + command + I;对于 Windows PC,使用 Ctrl + Shift + I。
在开发者工具中,你有一系列标签(元素、网络、源等)。这些工具非常有价值,将允许你检查代码的不同方面。有关 Chrome 开发者工具的更多信息,请访问此链接:developer.chrome.com/devtools/docs/authoring-development-workflow
。
由于我们将重点放在元素标签上,如果它尚未选中,请点击它。
你应该看到与前面的截图类似的内容;它将包含以下代码语句:
<svg width="812" height="584">
如果你点击 SVG 项目,你应该看到它展开并显示路径标签。路径标签将包含与 d
属性相关联的几个数字和字符。这些数字是绘制路径的控制点。我们将在下一章中介绍路径的绘制方式以及路径标签如何用于创建地图,在第四章, 创建地图和第五章, 点击-点击爆炸!为你的地图添加交互性。
我们还希望引起你的注意,HTML5 应用程序如何加载 D3 库。同样,在元素标签中,在 SVG 标签之后,你应该看到指向 D3.js 和 TopoJSON 的 <script>
标签:
<script src="img/d3.v4.min.js"></script>
<script src="img/topojson.v3.min.js"></script>
如果你点击位于 SVG 标签内的路径,你会看到一个名为 CSS 检查器或样式检查器的新面板。它显示并控制应用于所选元素的所有样式,在这种情况下,是路径元素。
这三个组件创建了一个 D3 可视化:
-
HTML5(SVG 和路径元素)
-
JavaScript(D3.js 库和地图代码)
-
CSS(HTML5 元素的样式)
在整本书中,将讨论和分析使用这三个组件创建地图和可视化。
概述
本章简要介绍了基本设置的步骤,以便有一个组织良好的代码库来使用 D3 创建地图。你应该熟悉这个设置,因为我们将在整个书中使用这个约定。
剩余的章节将专注于使用 HTML、JavaScript 和 CSS 创建详细地图和实现逼真的可视化。
让我们开始吧!
第二章:从简单文本创建图像
在本章中,将通过解释其操作方式和包含的元素来介绍可缩放矢量图形(SVG)。在浏览器环境中,SVG 与 HTML 非常相似,并且是 D3 表达其功能的一种方式。理解 SVG 的节点和属性将使我们能够创建许多类型的可视化,而不仅仅是地图。本章包括以下内容:
-
SVG 及其关键元素的概述
-
SVG 坐标系
-
SVG 的主要元素(线条、矩形、圆、多边形和路径)
SVG,一种 XML 标记语言,旨在描述二维矢量图形。SVG 标记语言位于 DOM 中,作为一个节点,精确地描述了如何绘制一个形状(曲线、线、圆或多边形)。就像 HTML 一样,SVG 标签也可以通过标准 CSS 进行样式化。请注意,由于所有命令都位于 DOM 中,形状越多,节点就越多,浏览器的工作量就越大。这一点很重要,因为随着 SVG 可视化变得更加复杂,它们的性能将变得不那么流畅。
主要的 SVG 节点声明如下:
<svg width="200" height="200"></svg>
此节点的基本属性是宽度和高度;它们为构成可视化的其他节点提供了主要容器。例如,如果你想在200
x 200
的框中创建 10 个连续的圆,标签看起来会是这样:
<?xml version="1.0"?>
<svg width="200" height="200">
<circle cx="60" cy="60" r="50"/>
<circle cx ="5" cy="5" r="10"/>
<circle cx="25" cy="35" r="45"/>
<circle cx="180" cy="180" r="10"/>
<circle cx="80" cy="130" r="40"/>
<circle cx="50" cy="50" r="5"/>
<circle cx="2" cy="2" r="7"/>
<circle cx="77" cy="77" r="17"/>
<circle cx="100" cy="100" r="40"/>
<circle cx="146" cy="109" r="22"/>
</svg>
注意,10 个圆需要 10 个 DOM 节点,加上其容器。
SVG 包含几个原语,允许开发者快速绘制形状。
我们将在本章中介绍以下原语:
-
circle
:具有定义的半径和位置属性的常规圆 -
rect
:具有高度、宽度和位置属性的常规矩形 -
polygon
:任何多边形,由一系列点描述 -
line
:具有起点和终点的线 -
path
:通过一系列绘图命令创建的复杂线
SVG 坐标系
关于位置的问题?这些原语在 SVG 元素内部绘制在哪里?如果你想在左上角放一个圆,在右下角放另一个圆怎么办?从哪里开始?
SVG 通过一个类似于笛卡尔坐标系的网格系统进行定位。然而,在 SVG 中(0,0)是左上角。x轴从左到右水平延伸,起始值为 0。y轴也从 0 开始,向下延伸。请参见以下插图:
关于在形状上方绘制形状的问题?如何控制z索引?在 SVG 中,没有z坐标。深度由绘制形状的顺序决定。如果你要绘制一个坐标为(10,10)的圆,然后绘制另一个坐标为(10,10)的圆,你会看到第二个圆覆盖在第一个圆的上方。
以下章节将介绍用于绘制形状的基本 SVG 原语及其一些最常见属性。
线
SVG 线条是库中最简单的一种。它从
从一个点到另一个点。语法非常简单,可以在以下位置进行实验:http://localhost:8080/chapter-2/line.html
,假设 HTTP 服务器正在运行:
<line x1="10" y1="10" x2="100" y2="100" stroke-width="1"
stroke="red"/>
这将给出以下输出:
元素的属性描述如下:
-
x1
和y1
: 起始的x和y坐标 -
x2
和y2
: 结束的x和y坐标 -
stroke
: 这会给线条一个红色 -
stroke-width
: 这表示要绘制的线条的像素宽度
line
标签也有改变线条末端风格的能力。例如,添加以下内容将改变图像,使其具有圆形末端:
stroke-linecap: round;
如前所述,所有 SVG 标签也可以使用 CSS 元素进行样式设置。产生相同图形的另一种方法是在以下代码中首先创建一个 CSS 样式:
line {
stroke: red;
stroke-linecap: round;
stroke-width: 5;
}
然后你可以使用以下代码创建一个非常简单的 SVG 标签:
<line x1="10" y1="10" x2="100" y2="100"></line>
使用path
标签可以实现更复杂的线条以及曲线;我们将在路径部分进行介绍。
长方形
创建矩形的 HTML 代码如下:
<rect width="100" height="20" x="10" y="10"></rect>
让我们应用以下样式:
rect {
stroke-width: 1;
stroke:steelblue;
fill:#888;
fill-opacity: .5;
}
我们将创建一个以坐标(10
,10
)开始的长方形,宽度为100
像素,高度为20
像素。根据样式,它将有一个蓝色轮廓,灰色内部,并且看起来稍微不透明。请参见以下输出和示例
http://localhost:8080/chapter-2/rectangle.html
:
在创建圆角边框时,还有两个有用的属性
(rx
和ry
):
<rect with="100" height="20" x="10" y="10" rx="5" ry="5"></rect>
这些属性表示x
和y
角落将会有5
像素的曲线。
圆形
圆的位置由cx
和cy
属性确定。这些属性表示圆心的x和y坐标。半径由r
属性确定。以下是一个你可以实验的示例(http://localhost:8080/chapter-2/circle.html
):
<circle cx="62" cy="62" r="50"></circle>
现在输入以下代码:
circle {
stroke-width: 5;
stroke:steelblue;
fill:#888;
fill-opacity: .5;
}
这将创建一个具有熟悉的蓝色轮廓、灰色内部和半透明度的圆。
多边形
要创建多边形,请使用polygon
标签。最好的方法是将其与孩子的点对点游戏进行比较。你可以想象一系列的点,用笔将每个(x, y)坐标用直线连接起来。一系列的点在points
属性中标识。以下是一个示例(http://localhost:8080/chapter-2/polygon.html
):
<polygon points="60,5 10,120 115,120"/>
首先,我们从60,5
开始,移动到10,120
。然后,我们继续到115,120
,最后返回到60,5
。
笔自动返回到起始位置。
路径
当使用 D3 创建地图时,最常使用的是 path
SVG 标签。根据 W3C 的定义,你可以将 path
标签视为一系列命令,这些命令解释了如何通过在纸上移动笔来绘制任何形状。path
命令从放置笔的位置开始,然后是一系列后续命令,告诉笔如何用线条连接额外的点。path
形状也可以填充或对其轮廓进行样式化。
让我们看看一个非常简单的例子,复制我们创建的多边形三角形。
打开你的浏览器,转到 http://localhost:8080/chapter-2/path.html
,你将在屏幕上看到以下输出:
在三角形的任何位置右键单击并选择“检查元素”。
此形状的 path
命令如下:
<path d="M 120 120 L 220 220, 420 120 Z" stroke="steelblue"
fill="lightyellow" stroke-width="2"></path>
包含路径绘制命令的属性是 d
。命令遵循以下结构:
-
M
: 将笔放下,开始在x = 120 y = 120
处绘制 -
L
: 画一条直线连接 (120
,120
) 到x = 220 y = 220
,然后画另一条直线连接 (220
,220
) 到x = 420 y = 120
-
Z
: 将最后一个数据点 (420
,120
) 连接到起始点 (120
,120
)
实验
让我们尝试一些实验来巩固我们刚刚学到的知识。从 Chrome 开发者工具中,只需简单地从路径末尾移除 Z
,然后按 Enter:
你应该看到最上面的线条消失了。尝试在 L
子命令中更改数据点的其他实验。
曲线路径
路径也可以有曲线。概念仍然是相同的;你用线条连接几个数据点。主要区别在于现在你在连接点时应用曲线。有三种类型的曲线命令:
-
立方贝塞尔
-
二次贝塞尔
-
椭圆弧
每个命令都在 www.w3.org/TR/SVG11/paths.html
中详细解释。作为一个例子,让我们将立方贝塞尔曲线应用到三角形上。命令的格式如下:
C x1 y1 x2 y2 x y
此命令可以插入到路径结构中的任何位置:
-
C
: 表示我们正在应用立方贝塞尔曲线,就像前一个例子中的L
表示直线 -
x1
和y1
: 添加一个控制点以影响曲线的切线 -
x2
和y2
: 在应用x1
和y1
后添加第二个控制点 -
x
和y
: 表示线条最终停留的位置
要将此命令应用到我们之前的三角形上,我们需要将第二条线条命令 (320 120
) 替换为立方命令 (C 200 70 480 290 320 120
)。
之前,语句如下:
<path d="M 120 120 L 220 220, 320 120 Z"></path>
添加立方命令后,它将如下所示:
<path d="M 120 120 L 220 220, C 200 70 480 290 320 120 Z"></path>
这将产生以下形状:
为了说明立方贝塞尔曲线的工作原理,让我们画圆和线来显示 C
命令中的控制点:
<svg height="300" width="525">
<path d="M 120 120 L 220 220 C 200 70 480 290 320 120 Z ">
</path>
<line x1="220" y1="220" x2="200" y2="70"></line>
<circle cx="200" cy="70" r="5" ></circle>
<line x1="200" y1="70" x2="480" y2="290"></line>
<circle cx="480" cy="290" r="5"></circle>
<line x1="480" y1="290" x2="320" y2="120"></line>
</svg>
输出应该看起来像以下屏幕截图所示,可以在 http://localhost:8080/chapter-2/curves.html
进行实验。您可以看到由控制点(输出中用圆圈表示)和应用的立方贝塞尔曲线创建的角度。
SVG 路径是在绘制地理区域时利用的主要工具。然而,想象一下,如果您要手动使用 SVG 路径绘制整个地图,这项任务将变得非常耗时!例如,我们第一章中欧洲地图的命令结构有 3,366,121 个字符!即使是一个简单的州,如科罗拉多州,如果手动执行,代码也会很多:
<path id="CO_1_"
style="fill:#ff0000" d="M 115.25800,104.81000 L
116.51200,84.744000 L 117.00000,77.915000 L 106.82700,77.077000 L
99.371000,76.452000 L 88.014000,75.198000 L 81.709000,74.431000 L
80.907000,81.189000 L 79.932000,88.018000 L 78.788000,96.547000 L
78.329000,99.932000 L 78.154000,101.11800 L 88.641000,102.37200 L
99.898000,103.72200 L 109.88400,104.39200 L 111.91300,104.60300 L
115.39700,104.77700"/>
我们将在后面的章节中学习 D3 如何提供帮助。
变换
transform
允许您动态地更改您的可视化,这是使用 SVG 和命令绘制形状的优点之一。变换是您可以添加到我们迄今为止讨论的任何元素上的一个附加属性。在处理我们的 D3 地图时,两种重要的 transform
类型是:
-
平移:移动元素
-
缩放:调整元素中所有属性的坐标
翻译
您可能会在所有地图制图工作中使用这种变换,并在大多数在线的 D3 示例中看到它。作为一种技术,它通常与边距对象一起使用,以移动整个可视化。以下语法可以应用于任何元素:
transform="translate(x,y)"
在这里,x
和 y
是移动元素的坐标。
例如,一个平移 transform
可以使用以下代码将我们的圆向左移动 50
像素,向下移动 50
像素:
<circle cx="62" cy="62" r="50" transform="translate(50,50)"></circle>
这里是输出:
注意,半透明的图像代表原始图像和形状移动的起始位置。translate
属性不是绝对位置。它相对于 cx
、cy
调整圆的起点,并给这些坐标加上 50
。如果您要将圆移动到容器的左上角,您将使用负值进行平移。例如:
transform="translate(-10,-10)"
随意使用您的 Chrome 开发者工具或代码编辑器进行实验。
http://localhost:8080/chapter-2/translate.html
。
缩放
缩放变换易于理解,但如果您失去了缩放起源的焦点,它通常会创建不希望的效果。
缩放调整元素中所有属性的 (x, y) 值。使用之前的 circle
代码,我们有以下内容:
<circle cx="62" cy="62" r="50" stroke-width="5" fill="red"
transform="scale(2,2)"></circle>
缩放将使 cx
、cy
、半径和 stroke-width
加倍,产生以下输出(http://localhost:8080/chapter-2/scale.html
):
需要强调的是,因为我们使用 SVG 命令来绘制形状,所以在缩放图像时不会出现质量损失,这与 PNG 或 JPEG 这样的光栅图像不同。可以通过组合变换类型来调整缩放,改变形状的 x 和 y 位置。以下代码中使用了我们之前用过的 path
示例:
<path d="M 120 120 L 220 220 C 200 70 480 290 320 120 Z"
stroke="steelblue" fill="lightyellow" stroke-width="2"
transform="translate(-200,-200), scale(2,2)"></path>
上述代码将产生以下输出(http://localhost:8080/chapter-2/scale_translate.html
):
分组
<g>
组标签在 SVG 中经常使用,尤其是在地图中。它用于将元素分组,然后对该组应用一系列属性。它提供了以下好处:
-
它允许您将一组形状视为单个形状,用于缩放和平移。
-
它通过允许您在较高级别设置属性并让它们继承所有包含的元素来防止代码重复。
-
组对于以高效的方式应用变换到大量 SVG 节点至关重要。分组相对于父组进行偏移,而不是修改组中每个项目的每个属性。
让我们将用于解释贝塞尔曲线的形状集添加到一个单独的组中,在以下代码中结合我们迄今为止所学的一切:
<svg height="500" width="800">
<g transform="translate(-200,-100), scale(2,2)">
<path d="M 120 120 L 220 220 C 200 70 480 290 320 120 Z">
</path>
<line x1="220" y1="220" x2="200" y2="70"></line>
<circle cx="200" cy="70" r="5" ></circle>
<line x1="200" y1="70" x2="480" y2="290"></line>
<circle cx="480" cy="290" r="5" ></circle>
<line x1="480" y1="290" x2="320" y2="120"></line>
</g>
</svg>
上述代码将产生以下图像(http://localhost:8080/chapter-2/group.html
):
如果不使用组元素,我们就必须对集合中的所有六个形状应用变换、平移和缩放。分组帮助我们节省时间,并允许我们在将来快速调整对齐。
文本
文本元素,正如其名称所描述的,用于在 SVG 中显示文本。创建文本节点的基本 HTML 代码如下:
<text x="250" y="150">Hello world!</text>
它有一个 x
和 y
坐标来告诉它在 SVG 坐标系中从哪里开始写入。可以通过 CSS 类来实现样式,以便在我们的代码库中清晰地分离关注点。例如,查看以下代码:
<text x="250" y="150" class="myText">Hello world!</text>
.myText{
font-size:22px;
font-family:Helvetica;
stroke-width:2;
}
文本也支持旋转,以便在可视化中定位时提供灵活性:
<svg width="600" height="600">
<text x="250" y="150" class="myText"
transform="rotate(45,200,0)" font-family="Verdana"
font-size="100">Hello world!</text>
</svg>;
一些示例位于 http://localhost:8080/chapter-2/text.html
,并如图所示显示:
请记住,如果您旋转文本,它将相对于其原点(x 和 y)旋转。您可以通过 cx
和 cy
或在此情况下 250,150
指定平移的原点。请参阅代码中的 transform
属性以获得更多清晰度。
摘要
本章为我们提供了关于 SVG 的丰富信息。我们解释了路径、线条、圆形、矩形、文本及其一些属性。我们还介绍了通过缩放和变换形状的变换。由于本章为我们提供了一个坚实的基础,我们现在可以创建复杂的形状。下一章将介绍 D3 以及它是如何用于程序化地管理 SVG 的。我们继续前进!
第三章:从数据生成图形 - D3 的基础
我们已经获得了我们的工具箱并复习了 SVG 的基础知识。现在是时候探索 D3.js 了。D3 是 Protovis (mbostock.github.io/protovis/
) 库的进化。如果你已经深入研究数据可视化并对为你的网络应用制作图表感兴趣,你可能已经使用过这个库。还存在其他库,它们可以通过渲染图形的速度以及与不同浏览器的兼容性来区分。例如,Internet Explorer 不支持 SVG,但使用其自己的实现,VML。这使得 Raphaël.js
库成为一个极佳的选择,因为它可以自动映射到 VML 或 SVG。另一方面,jqPlot 使用简单,其简单的 jQuery 插件界面允许开发者快速采用它。
然而,Protovis 有其独特之处。鉴于该库的矢量性质,它允许你展示不同类型的可视化,以及生成流畅的过渡。请随意查看提供的链接并亲自验证。查看力导向布局:mbostock.github.io/protovis/ex/force.html
。在 2010 年,这些可视化既有趣又引人入胜,尤其是对于浏览器来说。
受 Protovis 的启发,斯坦福大学的一个团队(由 Jeff Heer、Mike Bostock 和 Vadim Ogievetsky 组成)开始专注于 D3。D3 及其应用于 SVG,为开发者提供了一个简单的方法来将他们的可视化绑定到数据并添加交互性。
对于研究 D3,有大量的信息可用。在 D3 网站上可以找到一份全面覆盖的宝贵资源:github.com/mbostock/d3/wiki
。在本章中,我们将介绍以下将在整本书中使用的概念:
-
创建基本的 SVG 元素
-
enter()
函数 -
update
函数 -
exit()
函数 -
AJAX
创建基本的 SVG 元素
在 D3 中,一个常见的操作是选择一个 DOM 元素并附加 SVG 元素。随后的调用将设置 SVG 属性,这些属性我们在第二章 创建简单的文本图像中已经学习过。D3 通过一种易于阅读的、功能性的语法——方法链来完成这个操作。让我们通过一个非常简单的例子来展示这是如何实现的(如果你正在运行 http-server,请访问 http://localhost:8080/chapter-3/example-1.html
):
var svg = d3.select("body")
.append("svg")
.attr("width", 200)
.attr("height", 200)
首先,我们选择 body
标签并向其中附加一个 SVG 元素。这个 SVG 元素的宽度和高度为 200
像素。我们还把选择存储在一个变量中:
svg.append('rect')
.attr('x', 10)
.attr('y', 10)
.attr("width",50)
.attr("height",100);
接下来,我们使用 svg
变量并添加一个 <rect>
项目到它。这个 rect
项目将从 (10
,10
) 开始,宽度为 50
,高度为 100
。从您的 Chrome 浏览器中,打开带有 Elements 选项卡的 Chrome 开发者工具并检查 SVG 元素:
注意模式:append('svg')
创建 <svg></svg>
。attr('width',200)
和 attr('height',200)
分别设置 width="200"
和 height="200"
。一起,它们产生了我们在上一章中学到的 SVG 语法:
<svg width="200" height="200">...</svg>
enter() 函数
enter()
函数是每个基本 D3 可视化的一部分。它允许开发者定义一个带有附加数据的开端点。enter()
函数可以被认为是一段代码,当数据首次应用于可视化时执行。通常,enter()
函数将跟随 DOM 元素的选择。让我们通过一个例子来了解一下 (http://localhost:8080/chapter-3/example-2.html
):
var svg = d3.select("body")
.append("svg")
.attr("width", 200)
.attr("height", 200);
创建 SVG 容器,就像我们之前做的那样,如下所示:
svg.selectAll('rect').data([1,2]).enter()
data
函数是我们将数据绑定到选择的方式。在这个例子中,我们将一个非常简单的数组 [1,2]
绑定到选择 <rect>
。enter()
函数将遍历 [1,2]
数组并应用后续的函数调用,如下面的代码所示:
.append('rect')
.attr('x', function(d){ return d*20; })
.attr('y', function(d){ return d*50; })
当我们遍历数组中的每个元素时,我们将执行以下操作:
-
添加一个新的
rect
SVG 元素 -
将
rect
元素定位在坐标 x = d * 20 和 y = d * 50,其中 d 对于第一个元素等于 1,对于第二个元素等于 2,如下面的代码所示:
.attr("width",50)
.attr("height",100);
我们将保持 height
和 width
不变:
<svg width="200" height="200">
<rect x="20" y="50" width="50" height="100"></rect>
<rect x="40" y="100" width="50" height="100"></rect>
</svg>
仔细观察;看看 Chrome 开发者工具。我们看到两个矩形,每个矩形对应于数组中的一个元素,如下面的截图所示:
记住,数据不一定非得是枯燥的数字,比如 1 或 2。数据数组可以由任何数据对象组成。为了说明这一点,我们将在下一个例子中将之前的数组更改为对象数组(见 http://localhost:8080/chapter-3/example-3.html
):
如您在下面的代码片段中看到的,我们的数据数组有两个对象,每个对象有四个不同的键值对:
var data = [
{
x:10,
y:10,
width:5,
height:40
},{
x:40,
y:10,
width:100,
height:40
}
];
var svg = d3.select("body")
.append("svg")
.attr("width", 200)
.attr("height", 200);
svg.selectAll('rect').data(data).enter()
.append('rect')
.attr('x', function(d){ return d.x})
.attr('y', function(d){ return d.y})
.attr("width", function(d){ return d.width})
.attr("height", function(d){ return d.height});
现在,当我们遍历数组中的每个对象时,我们将执行以下操作:
-
仍然添加一个新的
rect
SVG 元素。 -
通过对象的属性定位和调整
rect
元素的大小。第一个矩形将定位在x=10
,y=10
,宽度为5
,高度为40
。第二个矩形将定位在40
,10
,宽度为100
,高度为40
。 -
记住,
d
代表数据,或数组中的每个对象,这就是为什么我们用d.x
或d.y
来获取相应的x
和y
属性。
更新函数
不仅我们有矩形,我们还把它们连接到一个由两个对象组成的数据集中。这两个对象共享相同的属性,即x
、y
、width
和height
,因此很容易遍历它们并将值绑定到我们的可视化中。结果是静态 SVG 元素集。本节将介绍如何更新 SVG 元素和属性,当连接的数据发生变化时。让我们增强前面的例子来解释这是如何工作的(http://localhost:8080/chapter-3/example-4.html
):
function makeData(n){
var arr = [];
for (var i=0; i<n; i++){
arr.push({
x:Math.floor((Math.random() * 100) + 1),
y:Math.floor((Math.random() * 100) + 1),
width:Math.floor((Math.random() * 100) + 1),
height:Math.floor((Math.random() * 100) + 1)
})
};
return arr;
}
此函数创建了一个新的对象数组,具有随机的x
、y
、width
和height
属性。我们可以使用它来模拟数据的变化,允许我们创建具有不同属性的n
个项目:
var rectangles = function(svg) {
在这里,我们创建了一个函数,每次调用 D3 时都会将矩形插入 DOM 中。其描述如下:
var data = makeData(2);
让我们生成我们的假数据:
var rect = svg.selectAll('rect').data(data);
让我们选择我们的矩形并将其数据分配给它。这给我们一个变量,我们可以轻松地应用enter()
和update
。以下部分以详尽的方式编写,以精确说明enter()
、update
和exit()
正在发生的事情。虽然可以在 D3 中走捷径,但最好坚持以下风格以避免混淆:
// Enter
rect.enter().append('rect')
.attr('test', function(d,i) {
// Enter called 2 times only
console.log('enter placing initial rectangle: ', i)
});
如前文所述,对于数组中的每个元素,我们将其附加到一个矩形标签到 DOM 中。如果你在 Chrome 浏览器中运行此代码,你会注意到控制台只显示enter placing initial rectangle
两次。这是因为只有当数组中的元素多于 DOM 中的元素时,enter()
部分才会被调用:
// Update
rect.transition().duration(500).attr('x', function(d){
return d.x; })
.attr('y', function(d){ return d.y; })
.attr('width', function(d){ return d.width; })
.attr('height', function(d){ return d.height; })
.attr('test', function(d, i) {
// update every data change
console.log('updating x position to: ', d.x)
});
update
部分应用于原始选择集中的每个元素,但不包括已进入的元素。在前面的例子中,我们为每个数据对象设置了矩形的x
、y
、width
和height
属性。update
部分没有使用显式的update
方法。D3 在没有提供其他部分的情况下隐式地假设了一个update
调用。如果你在 Chrome 浏览器中运行代码,你会看到每次数据变化时控制台都会显示updating x position to:
:
var svg = d3.select("body")
.append("svg")
.attr("width", 200)
.attr("height", 200);
以下命令插入我们的工作 SVG 容器:
rectangles(svg);
以下命令绘制了我们可视化的第一个版本:
setInterval(function(){
rectangles(svg);
},1000);
setInterval()
函数是 JavaScript 中用于每x毫秒执行操作的函数。在这种情况下,我们每1000
毫秒调用一次rectangles
函数。
每次调用rectangles
函数时,它都会生成一个新的数据集。它具有与我们之前相同的属性结构,但与这些属性相关联的值是介于1和100之间的随机数。在第一次调用时,将调用enter()
部分,并创建我们的初始两个矩形。每1000
毫秒,我们使用相同的数据结构但不同的随机属性属性重新调用rectangles
函数。因为结构相同,所以现在跳过了enter()
部分,只对现有的矩形应用update
。这就是为什么每次绘图时我们都会得到具有不同尺寸的相同矩形。
update
方法非常有用。例如,您的数据集可以与股市相关联,并且您可以每毫秒更新一次可视化以反映股市的变化。您还可以将更新绑定到由用户触发的事件,并让用户控制可视化。选项是无限的。
exit()
函数
我们已经讨论了enter()
和update
。我们看到了一个如何确定可视化起点,另一个如何根据传入的新数据修改其属性。然而,先前的例子中数据元素的数量与属性完全相同。如果我们的新数据集包含不同数量的项目会发生什么?如果它有更多或更少呢?
让我们以前面的update
部分为例,稍作修改以展示我们正在讨论的内容(http://localhost:8080/chapter-3/example-5.html
):
我们可以通过对rectangles
函数进行两个小的修改来解释它是如何工作的:
var rectangles = function(svg) {
var data = makeData((Math.random() * 5) + 1);
在这里,我们告诉data
函数创建一个随机数量的data
对象:
var rect = svg.selectAll('rect').data(data);
// Enter
rect.enter().append('rect')
.attr('test', function(d,i) {
// Enter called 2 times only
console.log('enter placing inital rectangle: ', i)
});
// Update
rect.transition().duration(500).attr('x', function(d){ return d.x; })
.attr('y', function(d){ return d.y; })
.attr('width', function(d){ return d.width; })
.attr('height', function(d){ return d.height; })
.attr('test', function(d, i) {
// update every data change
console.log('updating x position to: ', d.x)
});
exit()
函数将与之前相同。添加一个新的exit()
部分:
// Exit
rect.exit().attr('test', function(d) {
console.log('no data...')
}).remove();
}
exit()
方法的作用是清除或清理我们可视化中不再使用的 DOM 元素。这很有帮助,因为它允许我们将数据与 DOM 元素同步。记住这个方法的一个简单方法是:如果有比 DOM 元素更多的数据元素,将调用enter()
部分;如果有比 DOM 元素更少的数据元素,将调用exit()
部分。在先前的例子中,如果没有匹配的数据,我们就移除了 DOM 元素。
以下是在调用enter()
和update
函数时发生的序列的图形表示。注意,对于数据元素6没有 DOM 元素,因此执行了enter()
部分。对于数据元素0到5,始终调用更新代码。对于数据元素6,在enter
过程完成后,将执行update部分。请参考以下图表:
AJAX
异步 JavaScript 和 XML(AJAX)并不完全与 D3 相关。它实际上基于 JavaScript。简而言之,AJAX 允许开发者从网页的背景中获取数据。这种技术在地图开发中非常有用,因为地理数据集可以非常大。从背景获取数据将有助于产生更精细的用户体验。此外,在第六章“查找和使用地理数据”中,我们将介绍压缩地理数据大小的技术。
将数据与代码库分离也将带来以下优势:
-
一个更轻量级的代码库,更容易管理
-
能够在不更改代码的情况下更新数据
-
使用第三方数据源提供者的能力
这是通过使用 D3 函数通过 AJAX 调用获取数据来实现的。让我们来检查以下代码:
d3.json("data/dataFile.json", function(error, json) {
d3.json()
方法有两个参数:文件路径和回调函数。回调函数指示在数据传输后要做什么。在前面的代码中,如果调用正确获取了数据,它将数据分配给json
变量。error
变量只是一个通用错误对象,指示在获取数据时是否出现了任何问题:
if (error) return console.log(error);
var data = json;
我们将 JSON 数据存储到 data 变量中,并继续像之前示例中那样处理它:
var svg = d3.select("body")
.append("svg")
.attr("width", 200)
.attr("height", 200);
svg.selectAll('rect')
.data(data).enter()
.append('rect')
.attr('x', function(d){ return d.x; })
.attr('y', function(d){ return d.y; })
.attr("width", function(d){ return d.width; })
.attr("height", function(d){ return d.height; });
});
D3 为我们提供了许多种数据获取方法,JSON 只是其中一种。它还支持 CSV 文件、纯文本文件、XML 文件,甚至是整个 HTML 页面。我们强烈建议您在以下文档中阅读有关 AJAX 的内容:github.com/d3/d3/blob/master/API.md#requests-d3-request
。
摘要
在本章中,我们解释了 D3 的核心元素(enter()
、update
和exit()
)。
我们理解了将数据与可视化结合的强大功能。数据不仅可以来自许多不同的来源,而且可视化也可以自动更新。
在 D3 Gallery 中可以找到许多详细的示例:github.com/mbostock/d3/wiki/Gallery
。
在下一章中,我们将结合所有这些技术从头开始构建我们的第一张地图。准备好吧!
第四章:创建地图
到目前为止,我们已经经历了一段相当精彩的旅程。我们探讨了创建地图的所有不同方面。我们接触了 SVG、JavaScript 和 D3 的基础知识。现在,是时候将所有这些部件组合起来,并真正拥有一个最终交付的产品了。在本章中,我们将通过一系列实验来涵盖以下主题:
-
基础 - 创建您的基本地图
-
实验 1 - 调整边界框
-
实验 2 - 创建渐变图
-
实验 3 - 向我们的可视化添加点击事件
-
实验 4 - 使用更新和过渡来增强我们的可视化
-
实验 5 - 添加兴趣点
-
实验 6 - 将可视化作为兴趣点添加
基础 - 创建您的基本地图
在本节中,我们将介绍创建标准地图的基础知识。
您可以通过打开本书提供的本章节的example-1.html
文件来查看示例。如果您已经启动了 HTTP 服务器,您可以将浏览器指向http://localhost:8080/chapter-4/example-1.html
。屏幕上显示的是墨西哥(奥斯卡钟爱的国家)!
让我们逐步分析代码,以了解如何创建此地图。
width
和height
可以是您想要的任何值。根据您的地图将在哪里可视化(手机、平板电脑或桌面),您可能需要考虑提供不同的width
和height
:
var height = 600;
var width = 900;
下一个变量定义了一个投影算法,它允许您从地图空间(纬度和经度)到笛卡尔空间(x, y)进行转换——基本上是将纬度和经度映射到坐标。您可以将投影视为将三维地球映射到平面的一种方式。有许多种类的投影,但geoMercator()
通常是您将使用的默认值:
var projection = d3.geoMercator();
var mexico = void 0;
如果您正在制作美国的地图,您可以使用一个更好的投影,称为 AlbersUsa。这是为了更好地定位阿拉斯加和夏威夷。通过创建geoMercator()
投影,阿拉斯加将按其大小渲染,与美国全境相媲美。geoAlbersUsa()
投影抓取阿拉斯加,使其变小,并将其放置在可视化的底部。以下截图是geoMercator()
:
下一个截图是geoAlbersUsa()
:
D3 库目前包含许多内置的投影算法。每个算法的概述可以在github.com/d3/d3-geo/blob/master/README.md#projections
查看。
接下来,我们将投影分配给我们的geoPath()
函数。这是一个特殊的 D3 函数,它将 JSON 格式的地理数据映射到 SVG 路径。geoPath()
函数所需的数据格式称为 GeoJSON,将在第六章中介绍,寻找和使用地理数据:
var path = d3.geoPath().projection(projection);
var svg = d3.select("#map")
.append("svg")
.attr("width", width)
.attr("height", height);
包括数据集
所需数据已提供给您,位于data
文件夹中,文件名为geo-data.json
:
d3.json('geo-data.json', function(data) {
console.log('mexico', data);
我们从 AJAX 调用中获取数据,正如我们在上一章中看到的。
数据收集完毕后,我们只想绘制我们感兴趣的数据部分。此外,我们还想自动调整地图的比例,以适应我们可视化定义的高度和宽度。
如果你查看控制台,你会看到mexico
有一个objects
属性。嵌套在objects
属性内部的是MEX_adm1
。这代表墨西哥的行政区域。理解你正在使用的地理数据非常重要,因为其他数据源可能对行政区域属性的命名不同:
注意到MEX_adm1
属性包含一个包含 32 个元素的geometries
数组。这些元素中的每一个代表墨西哥的一个州。使用这些数据来绘制 D3 可视化:
var states = topojson.feature(data, data.objects.MEX_adm1);
在这里,我们将所有行政区域传递给topojson.feature()
函数,以便提取并创建一个 GeoJSON 对象数组。前面的states
变量现在包含features
属性。这个features
数组是一个包含 32 个 GeoJSON 元素的列表,每个元素代表墨西哥一个州的地理边界。我们将初始比例和转换设置为1
和[0,0]
:
// Setup the scale and translate
projection.scale(1).translate([0, 0]);
此算法非常有用。边界框是一个球形框,返回包含地理数据的最小/最大坐标的二维数组:
var b = path.bounds(states);
引用 D3 文档:
"边界框由一个二维数组表示:[[左,下],[右,上]],其中左是最小经度,下是最小纬度,右是最大经度,上是最大纬度。"
如果你想以编程方式设置地图的比例和转换,这将非常有用。在这种情况下,我们希望整个国家都能适应我们的height
和width
,因此我们确定墨西哥国家中每个州的边界框。
比例尺是通过将我们边界框的最长地理边长除以该边在可视化中的像素数来计算的:
var s = .95 / Math.max((b[1][0] - b[0][0]) / width, (b[1][1] -
b[0][1]) / height);
这可以通过首先计算width
的比例,然后计算height
的比例,最后取两个中的较大值来计算。所有逻辑都压缩在前面给出的单行中。这三个步骤在下图中解释:
95
这个值调整了比例,因为我们给地图边缘留出了一些空间,以便路径不会与 SVG 容器元素的边缘相交,基本上减少了 5% 的比例。
现在,我们有了地图的准确比例,这是基于我们设置的 width
和 height
:
var t = [(width - s * (b[1][0] + b[0][0])) / 2, (height - s *
(b[1][1] + b[0][1])) / 2];
正如我们在 第二章 中看到的,从简单的文本创建图像,当我们对 SVG 进行缩放时,它会缩放所有属性(甚至 x 和 y)。为了将地图返回到屏幕中心,我们将使用 translate()
函数。
translate()
函数接收一个包含两个参数的数组:沿 x 方向平移的量,以及沿 y 方向平移的量。我们将通过找到中心 (topRight - topLeft)/2 并乘以比例来计算 x。然后从 SVG 元素的宽度中减去这个结果。
我们的 y 平移计算方式类似,但使用 bottomRight - bottomLeft 的值除以 2,乘以比例,然后从 height
中减去。
最后,我们将重置投影以使用我们新的比例和平移:
projection.scale(s).translate(t);
在这里,我们将创建一个地图变量,它将把所有下面的 SVG 元素组合到一个 <g>
SVG 标签中。这将允许我们应用样式并更好地包含所有前面的路径元素:
var map = svg.append('g').attr('class', 'boundary');
最后,我们回到了经典的 D3 enter、update 和 exit 模式。我们有我们的数据,墨西哥各州的列表,我们将把这个数据连接到 path
SVG 元素上:
mexico = map.selectAll('path').data(states.features);
//Enter
mexico.enter()
.append('path')
.attr('d', path);
Enter
部分和相应的 path
函数在数组中的每个数据元素上执行。作为一个提醒,数组中的每个元素代表墨西哥的一个州。path
函数已经被设置好,以正确绘制每个州的轮廓,并将其缩放和平移以适应我们的 SVG 容器。
恭喜!你已经创建了你的第一张地图!
实验 1 – 调整边界框
现在我们有了基础,让我们开始我们的第一个实验。对于这个实验,我们将使用上一节学到的知识手动放大到墨西哥的一个州。代码可以在 example-2.html
中找到(http://localhost:8080/chapter-4/example-2.html
);然而,你可以自由地编辑 example-1.html
来边学边做。
对于这个实验,我们将修改一行代码:
var b = path.bounds(states.features[5]);
在这里,我们告诉计算基于 features
数组的第六个元素创建边界,而不是墨西哥国家的每个州。边界数据现在将运行通过其余的缩放和平移算法,以调整地图到下一张截图所示:
我们基本上将边界框的最小/最大值减少,以包含墨西哥一个州的地理坐标(见下一张截图),D3 会自动缩放和平移这些信息:
这在您可能无法从周围区域独立获取所需数据的情况下非常有用。因此,您始终可以放大您感兴趣的地区,并将其从其他区域中分离出来。
实验 2 – 创建渐变图
D3.js 地图的常见用途之一是制作渐变图。这种可视化方式让您能够区分不同地区,并为它们赋予不同的颜色。通常,这种颜色与某些其他值相关联,例如流感水平或公司的销售额。在 D3.js 中制作渐变图非常简单。在这个实验中,我们将基于所有州数组中州的索引值创建一个快速的渐变图。查看以下代码,或使用您的浏览器并访问此处:http://localhost:8080/chapter-4/example-3.html
。
我们只需要修改 D3 代码中 Update
部分的两行代码。在 enter()
部分之后,添加以下两行:
//Update
var color = d3.scaleLinear().domain([0,33]).range(['red',
'yellow']);
//Enter
mexico.enter()
.append('path')
.attr('d', path)
.attr('fill', function(d,i){
return color(i);
});
color
变量使用了另一个非常有价值的 D3 函数,名为 scale
。在 D3 中创建可视化时,缩放功能非常强大;有关缩放的更多详细信息,请参阅:github.com/d3/d3/blob/master/API.md#scales-d3-scale
。
现在,让我们描述一下这个缩放定义了什么。在这里,我们创建了一个名为 color()
的新函数。这个 color()
函数在一个输入域中寻找介于 0
和 33
之间的任何数字。D3 将这些输入值线性映射到输出范围中的红色和黄色之间的颜色。D3 包含了将线性范围内的颜色自动映射到渐变的特性。这意味着执行新的 color
函数,使用 0
将返回红色,color(15)
将返回橙色,而 color(33)
将返回黄色。
这里有一个小表格,仅用于视觉参考。它显示了颜色及其相应的 RGB 值:
现在,在更新部分,我们将路径的 fill
属性设置为新的 color()
函数。这将提供一个颜色线性缩放,并使用索引值 i
来确定应该返回什么颜色。
如果颜色是由数据点的不同值确定的,例如 d.scales
,那么您将有一个颜色实际上代表销售额的渐变图。前面的代码应该渲染如下:
实验 3 – 为我们的可视化添加点击事件
我们已经看到了如何制作地图并为地图的不同区域设置不同的颜色。接下来,我们将添加一些交互性。这将展示如何将点击事件绑定到地图上的简单示例。对于这个实验,我们将基于之前的练习,example-3.html
。您可以在以下位置查看完成的实验:http://localhost:8080/chapter-4/example-4.html
。
首先,我们需要对国家中的每个州有一个快速参考。为此,我们将在mexico
变量下方创建一个新的函数,名为geoID
:
var height = 600;
var width = 900;
var projection = d3.geoMercator();
var mexico = void 0;
var geoID = function(d) {
return "c" + d.properties.ID_1;
};
这个函数接收一个state
数据元素,并根据数据中找到的ID_1
属性生成一个新的可选择的 ID。ID_1
属性包含数组中每个状态唯一的数值。如果我们将其作为id
属性插入到 DOM 中,那么我们将创建一个快速且简单的方法来选择国家中的每个状态。
下面的geoID()
函数创建了一个名为click
的另一个函数:
var click = function(d) {
d3.selectAll('path').attr('fill-opacity',0.2)
d3.select('#' + geoID(d)).attr('fill-opacity', 1);
};
这个方法使得分离click
所做的工作变得容易。click
方法接收数据项并改变所有州的填充不透明度值为0.2
。这样做是为了当你点击一个州然后点击另一个州时,之前的州不会保持clicked
样式。注意,函数调用正在使用 D3 更新模式遍历 DOM 的所有元素。在使所有州透明后,我们将给定点击项的填充不透明度设置为1
。这将从所选州中移除所有透明样式。注意,我们正在重用之前创建的geoID()
函数,以快速找到 DOM 中的州元素。
接下来,让我们更新enter()
方法,将我们的新click
方法绑定到enter()
附加的每个新 DOM 元素:
//Enter
mexico.enter()
.append('path')
.attr('d', path)
.attr('id', geoID)
.on("click", click)
.attr('fill', function(d,i) { return color(i); })
我们还添加了一个名为id
的属性;这会将geoID()
函数的结果插入到id
属性中。同样,这使得找到被点击的州变得非常容易。
代码库应该生成如下所示的地图。检查一下,并确保点击任何州。你会看到它的颜色比周围州稍微亮一些:
实验 4 – 使用更新和过渡来增强我们的可视化
在我们的下一个实验中,我们将结合所有知识,并为地图添加一些平滑的过渡。过渡是添加风格和流畅数据变化的绝佳方式。
这个实验再次需要我们从example-3.html
开始。完整的实验可以在http://localhost:8080/chapter-4/example-5.html
查看。
如果你还记得,我们利用 JavaScript 的setInterval()
函数以固定的时间频率执行更新。现在我们将回到这个方法,为现有的color()
函数分配一个介于 1 和 33 之间的随机数。然后我们将利用 D3 方法在随机颜色变化之间进行平滑过渡。
在更新部分下方,添加以下setInterval()
代码块:
setInterval(function(){
map.selectAll('path').transition().duration(500)
.attr('fill', function(d) {
return color(Math.floor((Math.random() * 32) + 1));
});
},2000);
此方法表示,对于每 2000
毫秒(2 秒),应执行 map
更新部分,并将颜色设置为 1
到 32
之间的随机数。新的 transition
和 duration
方法在 500
毫秒内从上一个状态过渡到新状态。在浏览器中打开 example-5.html
,你应该看到基于状态索引的初始颜色。2 秒后,颜色应平滑过渡到新值。
实验 5 – 添加兴趣点
到目前为止,我们所做的一切都涉及到直接处理地理数据和地图。然而,有许多情况需要你在地图上叠加额外的数据。我们将首先通过在墨西哥地图上添加一些感兴趣的城市来慢慢开始。
这个实验再次需要我们从 example-3.html
开始。完整的实验可以在:http://localhost:8080/chapter-4/example-6.html
上查看。
在这个实验中,我们将在页面上添加一个 text
元素来标识城市。为了使文本更具视觉吸引力,我们首先在 <style>
部分添加一些简单的样式:
text{
font-family: Helvetica;
font-weight: 300;
font-size: 12px;
}
接下来,我们需要一些数据来指示城市名称、纬度和经度坐标。为了简化,我们添加了一个包含几个起始城市的文件。名为 cities.csv
的文件与示例在同一目录中:
name,lat,lon,
Cancun,21.1606,-86.8475
Mexico City,19.4333,-99.1333
Monterrey,25.6667,-100.3000
Hermosillo,29.0989,-110.9542
现在,添加几行代码来引入数据,并在你的地图上绘制城市位置和名称。在退出部分(如果你从 example-2.html
开始)下方添加以下代码块:
d3.csv('cities.csv', function(cities) {
var cityPoints = svg.selectAll('circle').data(cities);
var cityText = svg.selectAll('text').data(cities);
cityPoints.enter()
.append('circle')
.attr('cx', function(d) {
return projection ([d.lon, d.lat])[0]
})
.attr('cy', function(d) {
return projection ([d.lon, d.lat])[1]
})
.attr('r', 4)
.attr('fill', 'steelblue');
cityText.enter()
.append('text')
.attr('x', function(d) {
return projection([d.lon, d.lat])[0]})
.attr('y', function(d) {
return projection([d.lon, d.lat])[1]})
.attr('dx', 5)
.attr('dy', 3)
.text(function(d) {return d.name});
});
让我们回顾一下我们刚刚添加的内容。
d3.csv
函数将向我们的数据文件发出 AJAX 调用,并将整个文件自动格式化为 JSON 对象的数组。对象的每个属性都将采用 .csv
文件中相应列的名称。例如,看看以下代码行:
[{
"name": "Cancun",
"lat":"21.1606",
"lon":"-86.8475"
}, ...]
接下来,我们定义两个变量来保存我们的数据连接到圆形和文本 SVG 元素。
最后,我们将执行一个典型的输入模式,将点放置为圆形,并将名称作为文本 SVG 标签放置在地图上。x 和 y 坐标是通过调用我们之前的 projection()
函数,并使用数据文件中的相应纬度和经度坐标来确定的。
注意,projection()
函数返回一个包含 x 和 y 坐标的数组 (x, y)。x 坐标是通过取返回数组的 0
索引来确定的。y 坐标是从索引 1
中确定的。例如,看看以下代码:
.attr('cx', function(d) {return projection([d.lon, d.lat])[0]})
这里,[0]
表示 x 坐标。
你新的地图应该看起来像以下截图所示:
实验 6 – 将可视化作为兴趣点添加
对于我们的最终实验,我们将在可视化之上叠加更多的可视化!从我们上次停止的地方http://localhost:8080/chapter-4/example-6.html
开始,我们将向数据中添加一个虚构的列来表示龙舌兰的消费指标(最终版本可以在http://localhost:8080/chapter-4/example-7.html
中查看):
name,lat,lon,tequila
Cancun,21.1606,-86.8475,85,15
Mexico City,19.4333,-99.1333,51,49
Monterrey,25.6667,-100.3000,30,70
Hermosillo,29.0989,-110.9542,20,80
只需两行代码,我们就可以让城市点表达意义。在这个实验中,我们将根据龙舌兰的消费量来调整城市圆的半径:
var radius = d3.scaleLinear().domain([0,100]).range([5,30]);
在这里,我们将引入一个新的比例,将输入值从1
到100
线性分布到5
到30
之间的半径长度。这意味着圆的最小半径将是5
,最大半径将是30
,防止圆变得过大或过小以至于无法阅读:
cityPoints.enter()
.append('circle')
.attr('cx', function(d) {
return projection([d.lon, d.lat])[0];})
.attr('cy', function(d) {
return projection([d.lon, d.lat])[1];})
.attr('r', 4)
.attr('fill', 'steelblue');
接下来,我们将更改前面的代码行,将其改为调用radius
函数而不是硬编码的4
值。现在的代码将看起来像这样:
.attr('r', function(d) {return radius(d.tequila); })
在这两个小添加之后,你的地图应该看起来像以下截图所示:
摘要
你学习了如何构建多种不同类型的地图,以满足不同的需求。色块图和地图的数据可视化是一些最常见的基于地理的数据表示形式,你将会遇到。我们还通过基本的过渡和事件为我们的地图添加了交互性。你将很容易意识到,凭借你迄今为止收集的所有信息,你可以独立创建引人入胜的地图可视化。你可以在下一章学习高级交互技术来扩展你的知识。
请系好安全带!
第五章:点击-点击-爆炸!将交互性应用到你的地图上
在上一章中,你学习了如何使用 D3.js 构建基本地图所需的内容。我们还讨论了 enter、update 和 exit 的概念以及它们如何应用于地图。你也应该理解 D3 如何将 HTML 与数据混合匹配。然而,假设你想更进一步,给你的地图添加更多交互性。在上一章中,我们只触及了冰山一角,关于点击事件。现在,是时候深入挖掘了。
在本章中,我们将扩展我们对事件和事件类型的知识。我们将通过实验和构建你所学的内容来逐步前进。本章涵盖了以下主题:
-
事件及其发生方式
-
实验 1 - 悬停事件和工具提示
-
实验 2 - 带有可视化的工具提示
-
实验 3 - 平移和缩放
-
实验 4 - 正射投影
-
实验 5 - 旋转正射投影
-
实验 6 - 拖动正射投影
事件及其发生方式
以下内容直接来自 w3 规范:
“事件接口用于向处理事件的处理器提供关于事件的上下文信息。实现事件接口的对象通常作为事件处理器的第一个参数传递。通过从事件派生出包含与伴随事件类型直接相关的信息的额外接口,可以更具体地传递上下文信息到事件处理器。这些派生接口也由传递给事件监听器的对象实现。”
换句话说,事件是在浏览器中发生的用户输入动作。如果你的用户点击、触摸、拖动或旋转,就会触发一个事件。如果你为这些特定事件注册了事件监听器,监听器将捕获事件并确定事件类型。监听器还将公开与事件相关的属性。例如,如果我们想在纯 JavaScript 中添加事件监听器,我们会添加以下几行代码:
<body>
<button id="btn">Click me</button>
<script>
varbtn = document.getElementById('btn');
btn.addEventListener('click', function() {
console.log('Hello world'); }, false );
</script>
</body>
注意,你首先需要在 DOM 中有按钮,以便获取其 ID。一旦你有了它,你就可以简单地添加一个事件监听器来监听元素的点击事件。每当点击事件触发时,事件监听器都会捕获点击事件并将Hello world
记录到控制台。
在 jQuery 之前,事件处理非常复杂,不同的浏览器有不同的捕获这些事件的方式。然而,幸运的是,这些都已成为过去。现在,我们生活在一个现代浏览器在事件处理上更加一致的世界。
在 D3 的世界中,你不必担心这个问题。生成事件、捕获它们和响应它们是库内置的,并且可以在所有浏览器中工作。一个很好的例子是悬停事件。
实验 1 – 悬停事件和工具提示
建立在先前的示例之上,我们可以轻松地将我们的click
方法与hover
方法交换。不再有var click
,我们现在将拥有var hover
以及相应的函数。请随意打开chapter-5
代码库中的example-1.html
来查看完整的示例(http://localhost:8080/chapter-5/example-1.html
)。让我们回顾一下将我们的点击事件更改为悬停事件的必要代码。在这种情况下,我们需要更多的 CSS 和 HTML。在我们的<style>
标签中,添加以下行:
#tooltip{
position: absolute;
z-index: 2;
background: rgba(0,153,76,0.8);
width:130px;
height:20px;
color:white;
font-size: 14px;
padding:5px;
top:-150px;
left:-150px;
font-family: "HelveticaNeue-Light", "Helvetica Neue Light",
"Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
}
此样式适用于基本工具提示。它被绝对定位,以便它可以接受我们给出的任何x和y坐标(左和顶)。它还有一些填充样式用于字体和颜色。tooltip
被样式化为 DOM 中具有 ID 为#tooltip
的元素:
<div id="tooltip"></div>
接下来,我们添加处理悬停事件的逻辑:
var hover = function(d) {
var div = document.getElementById('tooltip');
div.style.left = event.pageX +'px';
div.style.top = event.pageY + 'px';
div.innerHTML = d.properties.NAME_1;
};
此函数除了记录事件外,还将找到具有 ID 为tooltip
的 DOM 元素,并将其定位在事件的x和y坐标上。这些坐标是事件属性的一部分,分别命名为pageX
和pageY
。接下来,我们将插入包含状态名称(d.properties.NAME_1
)的文本到tooltip
中:
//Enter
mexico.enter()
.append('path')
.attr('d', path)
.on("mouseover", hover);
最后,我们将更改代码中的绑定,从点击事件更改为mouseover
事件。我们还将将事件处理程序更改为我们之前创建的hover
函数。
保存并查看更改后,你应该会在地图上注意到基本的工具提示:
实验 2 – 带有可视化的工具提示
在接下来的实验中,我们将通过额外的可视化增强我们的工具提示。以类似的方式,我们将概述额外的代码以提供此功能(http://localhost:8080/chapter-5/example-2.html
)。
我们需要向我们的 CSS 中添加以下代码行:
#tooltip svg{
border-top:0;
margin-left:-5px;
margin-top:7px;
}
这将使我们的 SVG 容器(在我们的 tooltip DOM 元素内部)与州的标签对齐。
接下来,我们将包含两个新的脚本以创建可视化:
<script src="img/base.js"></script>
<script src="img/sparkline.js"></script>
上述 JavaScript 文件包含创建折线图可视化的 D3 代码。图表本身包含并利用了 Mike Bostock 所描述的可重用图表:bost.ocks.org/mike/chart/
。请随意检查代码;这是一个非常简单的可视化,遵循进入、更新和退出的模式。我们将在第七章中进一步探讨此图表,测试:
var db = d3.map();
var sparkline = d3.charts.sparkline().height(50).width(138);
我们现在将声明两个新变量。db
变量将包含一个 hashmap,以便可以通过geoID
快速查找值。sparkline
变量是绘制我们的简单折线图的函数:
var setDb = function(data) {
data.forEach(function(d) {
db.set(d.geoID, [
{"x": 1, "y": +d.q1},
{"x": 2, "y": +d.q2},
{"x": 3, "y": +d.q3},
{"x": 4, "y": +d.q4}
]);
});
};
此函数解析数据并将其格式化为sparkline
函数可以使用的结构,以创建折线图:
var geoID = function(d) {
return "c" + d.properties.ID_1;
};
我们将把来自 第四章,创建地图,的 geoID
函数重新引入,以便为每个州快速创建唯一的 ID:
var hover = function(d) {
var div = document.getElementById('tooltip');
div.style.left = event.pageX +'px';
div.style.top = event.pageY + 'px';
div.innerHTML = d.properties.NAME_1;
var id = geoID(d);
d3.select("#tooltip").datum(db.get(id)).call(sparkline.draw);
};
对于我们的悬停事件处理程序,我们需要添加两行新代码。首先,我们将声明一个 ID 变量,它持有我们悬停的州的唯一 geoID
。然后,我们将调用我们的 sparkline
函数在 tooltip
选择中绘制折线图。数据是从前面的 db
变量中检索的。有关调用工作方式的更多信息,请参阅:developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/call
:
d3.csv('states-data.csv', function(data) {
setDb(data);
});
我们通过 AJAX 加载我们的 .csv
文件,并调用前面描述的 setDb()
函数。
您现在应该看到一个显示每个墨西哥州带有折线图的 tooltip
的地图。总之:
-
地图按常规绘制。
-
我们将创建一个小的查找
db
,其中包含关于每个州的额外数据。 -
然后,我们将注册一个悬停事件,当用户的鼠标悬停在某个州上时,该事件将被触发。
-
悬停事件被触发并检索关于该州的数据。
-
悬停事件还将州名放入 DOM 中,并调用一个函数来使用检索到的数据创建一个折线图:![img/2f2788b5-1820-4e55-b32f-d3465e138d29.png]
实验 3 – 平移和缩放
在处理地图时,一个非常常见的请求是提供在可视化周围平移和缩放的能力。当一个大地图包含大量细节时,这尤其有用。幸运的是,D3 提供了一个事件监听器来帮助实现此功能。在本实验中,我们将概述为您的地图提供基本平移和缩放原理。本实验需要我们从 example-1.html
开始;然而,您可以自由地查看 http://localhost:8080/chapter-5/example-3.html
以获取参考。
首先,我们将在 <style>
部分添加一个简单的 CSS 类;这个类将在整个地图上作为矩形。这将是我们可缩放的区域:
.overlay {
fill: none;
pointer-events: all;
}
接下来,我们需要定义一个函数来处理缩放监听器触发的事件。以下函数可以放在地图声明下方:
var zoomed = function () {
map.attr("transform", "translate("+ d3.event.translate + ")
scale(" + d3.event.scale + ")");
};
此函数利用了在平移和缩放时暴露的两个变量:d3.event.scale
和 d3.event.translate
。这些变量定义如下:
-
d3.event.scale
:这定义了以 SVG 缩放级别为单位的缩放级别。 -
d3.event.translate
:这定义了地图相对于鼠标的位置,以 SVG 平移为单位。
在获得这些信息后,我们可以将地图容器的 SVG 属性(缩放和平移)设置为事件变量:
var zoom = d3.behavior.zoom()
.scaleExtent([1, 8])
.on("zoom", zoomed);
.size([width, height]);
与悬停事件监听器类似,我们需要创建一个新的缩放事件监听器。在 zoom()
函数之后创建前面的函数。请注意,有一个额外的设置需要理解,即 scaleExtent()
。
scaleExtent()
设置提供了缩放量的缩放范围。数组中的第一个元素是地图可以缩放到的最大范围。数组中的第二个元素是地图可以缩放到最大的范围。记住,1
是基于我们来自 第四章,创建地图 的边界框公式的地图原始大小。scaleExtent()
可以设置为的最小值是 0
,用于缩放。在 example-3.html
中,改变这些数字以了解它们的工作方式。例如,如果你将 1
改为 5
,你会看到地图可以缩放到其原始大小的一半。
在以下位置可以查看此事件监听器的附加设置:github.com/mbostock/d3/wiki/Zoom-Behavior
:
svg.append("rect")
.attr("class", "overlay")
.attr("width", width)
.attr("height", height)
.call(zoom);
最后,在 mexico.exit
部分之后,我们将在整个可视化中添加一个透明的矩形并绑定新的监听器。记住,这个矩形使用我们在实验开始时定义的 CSS 类。
现在,你应该在墨西哥地图上拥有完整的缩放和平移功能。你可以双击来缩放或使用鼠标滚轮。这些交互也应该适用于平板电脑上的滑动和捏合手势:
实验 4 – 正射投影
对于本章接下来的实验,我们将转换方向,查看正射投影(在二维屏幕上表示三维地图)的交互性。为了更好地说明这些概念,整个地球比单个国家是一个更好的可视化。这个实验将从 http://localhost:8080/chapter-5/example-4.html
开始,并需要一个新数据文件,它已经为你提供了。你会注意到代码库几乎相同,除了以下三个我们将概述的更改:
var height = 600;
var width = 900;
var projection = d3.geo.orthographic().clipAngle(90);
var path = d3.geo.path().projection(projection);
首先,我们将我们的 d3.geo
投影从 d3.geo.mercator
更改为 d3.geo.orthographic
。我们还有一个额外的设置来配置:clipAngle
为 90
度。这将在地球中放置一个假想的平面并裁剪投影的背面:
d3.json('world.json', function(data) {
var countries = topojson.feature(data, data.objects.countries);
var map = svg.append('g').attr('class', 'boundary');
var world = map.selectAll('path').data(countries.features);
接下来,我们将用新的数据文件 world.json
替换旧的 geo-data.json
文件。我们还将为我们的数据连接设置新的变量,以便在代码中提供更好的可读性:
world.enter()
.append('path')
.attr('d', path);
如我们所见多次,我们将应用标准的 enter()
模式。你现在应该有一个静态的地球地图,如下面的截图所示。你也可以直接使用 example-4.html
。
在最后两节中,我们将使地球变得生动起来!
实验 5 – 旋转正射投影
我们之前的例子非常引人入胜。我们只用了几行代码,就从二维地图的可视化转换到了三维。下一步是动画化它。对于这个实验,请在代码示例中打开http://localhost:8080/chapter-5/example-5.html
。现在让我们把它拼凑起来:
var i = 0;
我们添加了一个索引变量来保存旋转速率。别担心,我们将在这里解释它是如何使用的:
d3.json('world.json', function(data) {
var countries = topojson.feature(data, data.objects.countries);
var mexico = countries.features[102];
由于墨西哥是宇宙的中心,需要特别注意,我们通过从国家的特征数组中提取相应的特征将其隔离到自己的变量中。这将允许我们单独操作它,而不影响地球的其他部分:
var map = svg.append('g').attr('class', 'boundary');
var world = map.selectAll('path').data(countries.features);
var mexico = map.selectAll('.mexico').data([mexico]);
接下来,我们将之前隔离的信息与自己的变量进行数据连接。这样,我们将有一个代表整个世界的地图,另一个只代表墨西哥的地图:
mexico.enter()
.append('path')
.attr('class', 'mexico')
.attr('d', path)
.style('fill', 'lightyellow').style('stroke', 'orange');
我们将注入墨西哥的地图,并应用包含与我们用于世界地图相同的投影的geo.path
。我们还将使用fill
CSS 样式添加浅黄色背景,并使用stroke
添加橙色边框:
setInterval(function() {
i = i+0.2;
// move i around in the array to get a feel for yaw, pitch
// and roll
// see diagram
projection.rotate([i,0,0])
world.attr('d', path);
mexico.attr('d', path)
.style('fill', 'lightyellow').style('stroke', 'orange');
}, 20);
这就是动作开始的地方,字面上讲。我们创建了一个每 20 毫秒执行一次的间隔。这个间隔包含一个函数,该函数使用我们的索引变量并将值增加0.2
。然后,这个值被应用到我们的投影的rotate
函数上。具体来说,我们将在这行代码上每20
毫秒调整旋转:
projection.rotate([i,0,0])
偏航由数组的第一个值表示(在这种情况下,i
),俯仰由第二个值表示,翻滚由第三个值表示。偏航、俯仰和翻滚是旋转角度,并应用于它们各自的向量。以下图像展示了这些角度的旋转方式:
在这里,我们看到偏航向量指向z方向,并围绕中心轴旋转。俯仰沿着我们的x轴,而偏航则围绕y轴旋转。希腊字符(在前面图像中的括号内)通常用来表示偏航、俯仰和翻滚。
在我们的例子中,索引变量i
正在增加,并分配给偏航旋转。这意味着我们的地球将围绕中心轴从左向右旋转。如果我们交换索引的位置,使其位于俯仰位置(第二个数组元素),我们的地球将垂直旋转:
project.rotate([0,i,0]);
最后,我们将使用相同的 D3 更新模式并更新所有路径以使用新的投影。试一试,玩一玩这个例子,看看地球是如何在不同方向上旋转的。完成后,你将在浏览器中看到旋转的地球,如下面的截图所示:
实验 6 – 拖动正射投影
对于我们的最后一个例子,我们将添加拖动地球的功能,这样用户就可以将其向左或向右旋转。从代码示例中打开http://localhost:8080/chapter-5/example-6.html
,让我们开始吧:
var dragging = function(d) {
var c = projection.rotate();
projection.rotate([c[0] + d3.event.dx/2, c[1], c[2]])
world.attr('d', path);
mexico.attr('d', path)
.style('fill', 'lightyellow').style('stroke', 'orange');
};
我们的第一段新代码是我们的拖动事件处理器。这个函数将在用户在屏幕上拖动鼠标时执行。算法执行以下步骤:
-
存储当前的旋转值。
-
根据拖动的距离更新投影的旋转。
-
更新世界地图中的所有路径。
-
更新墨西哥地图中的所有路径。
第二步需要更多的解释。就像d3.behavior.zoom
事件处理器一样,d3.behavior.drag
暴露了执行动作的信息。在这种情况下,d3.event.dx
和d3.event.dy
表示从上一个位置拖动的距离。c[0] + d3.event.dx/2
代码告诉我们,我们需要取上一个偏航值并加上用户执行的拖动量。我们将拖动量除以二,以减半旋转速度;否则,用户每拖动一个像素都将对应于1度的旋转:
var drag = d3.behavior.drag()
.on("drag", dragging);
接下来,我们将我们的dragging
方法绑定到拖动事件,就像我们之前看到的,通过点击、悬停和缩放:
svg.append("rect")
.attr("class", "overlay")
.attr("width", width)
.attr("height", height)
.call(drag);
最后,我们需要一个区域来绑定我们的拖动事件。使用我们之前的技术,我们将在可视化上添加一个透明的矩形。这将允许我们非常清楚地检测 SVG 元素上的x和y位置。
给它一个旋转!你会注意到,如果你点击并拖动世界,它将在相应的偏航方向上旋转:
摘要
我们介绍了许多示例,帮助你开始使用 D3 地图可视化进行交互。我们讨论了事件处理的基础,探索了将事件绑定到地图的各种方法,概述了两个d3.behavior
API,甚至涉足了正射投影。如果你想要深入了解世界旋转和相关的数学,请查看 Jason Davies 的文章:www.jasondavies.com/maps/rotate/
。
在绘制和与地图交互了两章之后,下一章将解释如何获取地理数据以创建你想要的任何地图。我们还将包括一些优化数据文件以供网页查看的技术。
第六章:寻找和使用地理数据
我们在之前的章节中花费了大量时间创建和交互地图。在我们所有的例子中,都包含了地理数据。在本章中,我们将解释如何找到关于世界上任何国家的地理数据。
通常我们需要两组数据来在 D3 中创建地图:
-
表示我们地图地理形状的数据集(地理数据)
-
我们想在地图上可视化的有意义的数据(例如,按美国国家的人口密度,或按世界各国的失业率)
本章的重点是理解、操作和优化地图可视化中的地理数据。我们将通过以下方式实现这些目标:
-
解释包含地理空间矢量数据的三种重要格式
-
寻找、下载和使用大量地图数据
-
使用技术构建适合您地图的正确地理数据文件
地理数据文件类型
有数十种文件格式可以表示地理信息。在本节中,我们将关注三种文件类型:shapefiles、GeoJSON 和 TopoJSON。
Shapefiles 是什么?我如何获取它们?
Shapefiles 是最受欢迎的基于向量的文件格式。它们包含代表地理边界的多边形和线条。Shapefile 格式是由 Esri 公司开发的,作为一个开放标准来与地理信息系统(GIS)一起使用。这种向量信息也可以描述其他地理实体(如河流、湖泊和铁路)。此外,该文件格式具有存储在可视化工作时有用的数据属性的能力(例如,地理对象名称、类型和一些关系)。对我们来说最重要的是,有一个位于diva-gis.org
的大型免费 shapefiles 存储库。这个存储库包含不同详细程度和粒度的大量数据。
不幸的是,对于我们的情况,shapefiles 是二进制格式,并且可能非常大。这使得它们在标准 Web 开发中使用非常困难,如果不是不可能的话。幸运的是,有一些工具可以帮助我们利用 shapefiles 的大型存储库,并将它们转换为 GeoJSON 和 TopoJSON。GeoJSON 和 TopoJSON 对 JavaScript 友好,体积更小,在我们的 Web 开发环境中更容易使用。在前面的章节中,所有地理数据都提供为 TopoJSON。
为特定国家获取 shapefiles
让我们从西班牙的地图开始,通过获取我们的第一个 shapefile 的过程:
-
访问
www.diva-gis.org/gdata
,从下拉列表中选择西班牙,如图下截图所示: -
一旦选择了西班牙,您将看到一大堆地理数据可供选择(道路、铁路等)。选择“行政区域”选项以绘制国家和地区的边界。点击“确定”;它将带您进入下载页面。
-
下载后,您将得到一个包含西班牙行政区域形状文件数据的
ESP_adm.zip
文件。 -
解压文件后,您会看到文件组织成递增的数字,从
ESP_adm0
到ESP_adm4
。ESP 代表国家的缩写,每个数字代表每个数据文件中找到的细节量的增加。例如,ESP_adm0
将仅绘制西班牙的轮廓,而ESP_adm3
将包括国家的省份。
GeoJSON
GeoJSON 是描述地理数据结构的特定 JSON 格式。重要的是要知道 GeoJSON 执行以下操作:
-
包含绘制地理数据所需的所有信息。
-
是一个标准的 JSON 格式,在构建网页时可以立即在 JavaScript 中使用。
-
在定义我们的
d3.geo.path
函数时,D3 需要它,如前几章所示。 -
精确定义每个地理形状。例如,如果两个国家共享边界,GeoJSON 文件将完全定义这两个国家,因此边界被定义了两次。它不提供任何优化数据文件的机制。
由于 D3 依赖于 GeoJSON,我们将解释规范的一些亮点。对于完整的解释,请参阅geojson.org
。
通常,您不会直接将 GeoJSON 文件集成到您的 D3 工作中。下一节中解释的 TopoJSON 提供了一个更紧凑的解决方案。然而,了解规范仍然很重要,所以让我们通过西班牙的 GeoJSON 来了解一下:
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"GADMID": 70,
"ISO": "ESP",
"NAME_ENGLI": "Spain",
"NAME_ISO": "SPAIN",
"NAME_FAO": "Spain",
"NAME_LOCAL": "España",
...
},
"geometry": {
"type": "MultiPolygon",
"coordinates": [
[
[
[
0.518472,
40.53236
],
[
0.518194,
40.53236
],
...
]
]
]
}
}
]
}
JSON 对象的第一个属性标识该 GeoJSON 文件为特征集合(FeatureCollection
)。集合的每个成员(在先前的features
属性中的数组)包含一个特殊格式的 JSON 对象,称为feature
。我们在前几章中使用的d3.geo.path
函数知道如何使用 SVG 路径将feature
对象转换为多边形。通过迭代这些特征的数组并逐个绘制每个多边形,我们创建了一个 D3 地图。
feature
对象必须遵循以下属性,以便 D3 将对象转换为多边形:
-
geometry
:这是另一个 GeoJSON 规范,它包含类型和坐标,指示如何精确绘制形状。我们不会花太多时间解释规范如何绘制对象。D3 会为我们做所有艰苦的工作。利用 enter/update/exit 模式,我们将一个特殊的d3.geo.path
函数传递给每个特征。这个函数将获取特征的相关几何信息,并为我们自动创建形状。 -
properties
:这是附加到特征上的任何附加数据。这是一个典型的名称/值对 JSON 对象。在前面的示例中,properties
属性被用来存储国家的名称。当我们需要稍后找到国家以绑定其他数据到可视化时,这非常有用。请参阅以下截图,了解可以绑定到特征对象上的属性示例: -
id
:这是一个占位符,可以用来存储集合中特定特征的唯一标识符。
在 D3 中使用仅 GeoJSON 的快速地图
让我们暂时假设 TopoJSON 不存在,并说明仅使用 GeoJSON 如何创建地图。这将在下一节中帮助说明 TopoJSON 的必要性。以下代码片段是一个快速示例,将所有内容结合起来;您也可以从 chapter-6
文件夹中打开 example-1.html
(http://localhost:8080/chapter-6/example-1.html
),在您的浏览器中查看以下代码生成的地图:
d3.json('geojson/spain-geo.json', function(data) {
var b, s, t;
projection.scale(1).translate([0, 0]);
var b = path.bounds(data);
var s = .9 / Math.max((b[1][0] - b[0][0]) / width,
(b[1][1] - b[0][1]) / height);
var t = [(width - s * (b[1][0] +
b[0][0])) / 2,
(height - s * (b[1][1] + b[0][1])) / 2];
projection.scale(s).translate(t);
map = svg.append('g').attr('class', 'boundary');
spain = map.selectAll('path').data(data.features);
注意,代码几乎与上一章的示例相同。唯一的例外是我们没有调用 topojson
函数(我们将在下一节中解释为什么 topojson
很重要)。相反,我们将 AJAX 调用中的数据直接传递到以下 enter()
调用的 data join 中:
spain.enter()
.append('path')
.attr('d', path);
});
如预期的那样,我们有了西班牙的地图:
虽然直接使用 GeoJSON 可能看起来是最好的方法,但存在一些问题。主要问题是,将 Esri shapefile 一对一转换为 GeoJSON 格式包含大量可能不必要的细节,这将创建一个巨大的 GeoJSON 文件。文件越大,下载所需的时间就越长。例如,spain-geo.json
生成了一个几乎 7 MB 的 GeoJSON 文件。
接下来,我们将探讨如何通过修改几个优化杠杆来帮助 TopoJSON,同时仍然保持重要的细节。
TopoJSON 基础
TopoJSON 是另一种基于 JSON 的格式,用于编码地理数据。如果你还记得,GeoJSON 是离散地描述地理数据的。这意味着 GeoJSON 的边界可以被描述两次。TopoJSON 格式消除了这种重复行为,通常创建的文件大小可以缩小 80%。当在网络上构建时,这种格式非常有用,因为数据传输大小起着重要作用。
TopoJSON 这个术语可能会令人困惑。让我们将其分解为其三个维度:
-
TopoJSON,序列化格式:实际序列化的 JSON 格式,用于描述如何绘制地理形状。
-
topojson,命令行工具:这是一个用户可以运行的程序,用于从 shapefile 创建 TopoJSON 文件。该实用程序包含许多杠杆,可以进一步减小文件大小。
-
topojson.js,JavaScript 库:在您的 D3 地图中使用的库,用于将 TopoJSON 序列化格式转换回 GeoJSON,以便
d3.geo.path
函数能够正确工作。
为了说明 TopoJSON 可以减少文件大小的程度,让我们使用命令行工具对之前下载的 shapefiles 执行命令。打开命令行,并在下载并解压ESP_adm.zip
文件的同一目录中执行以下操作:
topojson -o spain-topo.json -p -- ESP_adm0.shp
接下来,我们通过 AJAX 注入我们刚刚创建的topojson
文件:
并保留来自ESP_adm0
shapefile 的所有数据属性(使用-p
标志)
(注意,在命令行语法中,shapefile 需要位于--
之后)。
简化
首先,让我们比较 GeoJSON 与 TopoJSON 在相同地理区域上的文件大小:
-
此命令创建了一个名为
spain-topo.json
的新 TopoJSON 格式文件。 -
TopoJSON: 379 KB
这是一种令人难以置信的压缩率,而我们只是使用了默认设置!
-o
参数定义了结果 TopoJSON 文件的名字。
<script src="img/topojson.v1.min.js"></script>
首先,我们将 JavaScript 库作为<script>
标签添加到我们的文件中。现在你知道为什么我们一直在使用这个库:
d3.json('topojson/spain-topo.json', function(data) {
保留特定属性
var country = topojson.feature(data, data.objects.ESP_adm0);
我们添加额外的代码行以将 TopoJSON 格式转换为 GeoJSON 特征格式:
var b = path.bounds(country);
我们需要记住使用插值特征创建我们的边界框:
spain = map.selectAll('path').data(country.features);
现在,我们在新的数据上使用数据连接。正如预期的那样,我们将看到西班牙的地图。让我们在下面的屏幕截图中并排展示它们,以比较 GeoJSON 和 TopoJSON(GeoJSON 在左侧,TopoJSON 在右侧):
TopoJSON 命令行技巧
TopoJSON 的命令行文档非常完整(github.com/mbostock/topojson/wiki/Command-Line-Reference
)。然而,这里有一些快速简便的技巧来帮助你入门。
为了将 TopoJSON 整合到我们的地图中,我们需要使用topojson.js
JavaScript 库并修改几行代码。我们将从example-1.html
开始。最终版本可以在example-2.html
(http://localhost:8080/chapter-6/example-2.html
)中查看:
在 GeoJSON 部分,我们说明了数据属性通常是地理数据的一部分。topojson
命令允许你过滤掉你不想保留的属性,并为你想保留的属性提供更好的命名约定。这些功能在-p
标志中,并传递给命令。例如:
topojson -o spain-topo.json -p name=ISO -- ESP_adm0.shp
我们将创建 TopoJSON 文件,移除除 ISO 以外的所有属性,并将 ISO 属性重命名为易于识别的名称。您可以通过逗号分隔列表来指定多个属性:
-p target=source,target=source,target=source
GeoJSON: 6.4 MB
Mike Bostock 提供了一个关于简化及其工作原理的优秀教程,可以在bost.ocks.org/mike/simplify/
找到。
基本上,这是一种通过线简化算法来减少几何复杂度的方法。例如,如果你不需要一个国家非常崎岖的海岸线有太多细节,你可以应用线简化算法来平滑崎岖度,并显著减小 TopoJSON 文件的大小。你使用的命令行参数是 -s
以调整 TopoJSON 转换中的简化:
-p name=ISO -s 7e-7 -- ESP_adm0.shp
我们通常意识到,在处理 DIVA-GIS 的 shapefiles 时,最佳范围大约在 7e-7,以保持在每像素阈值内,这个阈值小于地图的面积。在这个范围内,尺寸压缩非常显著,并且对于网络开发来说,地图质量仍然非常可接受。考虑以下内容:
-
原始:378 KB,细节和质量极佳!
-
简化到 -s 7e-7:3.6 KB,质量可接受!
-
在 -s 7e-5 时非常简单:568 字节,但地图无法识别!
合并文件
最后一个技巧涉及将多个 shapefiles 合并成一个单独的 TopoJSON 文件。如果你需要单独的地理信息,但又想通过单个 AJAX 请求获取,这非常有用。要追加额外的文件,请在命令行中的 -
后面添加它们。考虑以下命令:
topojson -o ../topojson/spain-topo-simple.json -p name=ISO -s 7e-7 -
- ESP_adm0.shp ESP_adm1.shp
它将产生以下对象结构,其中 ESP_adm0
的数据是国家数据,而 ESP_adm1
是地区数据:
你还有机会在生成的 TopoJSON 文件中将它们映射到的对象重命名。同样,这可以帮助创建可读的代码。重命名遵循与重命名特定属性相同的约定。例如,输入以下命令:
topojson -o ../topojson/spain-topo-simple.json -p name=ISO -s 7e-7 -
- country=ESP_adm0.shp regions=ESP_adm1.shp
前面的命令将创建以下内容:
在这种情况下,你需要更改你的原始代码,如下所示:
var country = topojson.feature(data, data.objects.ESP_adm0);
你必须将其更改为以下代码:
var country = topojson.feature(data, data.objects.country);
这看起来要好得多!请查看 example-3.html
(http://localhost:8080/chapter-6/example-3.html
) 以了解所有这些信息是如何联系在一起的。
摘要
到这一点,你应该有信心可以找到并修改数据集以满足你的需求。我们已经涵盖了你可以获取数据的一些常见位置,并且我们已经提到了 TopoJSON 提供的不同类型的标志。有了这些技能,确保你的数据被修剪并且符合你的可视化需求就取决于你了。这完成了使用 D3 开发地图的循环。在下一章中,我们将通过专注于测试你的可视化来提高你的技艺。
第七章:测试
在本章中,我们将介绍几个有助于长期维护您的 D3 代码库的话题。目标是建立一个基础,以构建可重用资产,这些资产可以轻松地进行单元测试,同时利用已经在 JavaScript 社区中确立的流行工具和技术。
单元测试在任何软件开发项目中都很重要,尤其是在 D3 代码库中。通常,这些项目涉及大量应用分析或操作数据结构的代码。对于这类问题,单元测试可以帮助以下方面:
-
减少错误:自动化的测试套件将允许开发者分解并测试单个组件。这些测试将在整个开发周期中不断运行,验证新功能不会破坏旧的工作代码。
-
准确记录:通常,测试是以人类可读的方式编写的;它们精确地描述了它们正在测试的问题。提供的代码示例比长段落提供了更好的文档。
-
允许重构:开发者可以自信地更改代码语义和设计,因为他们知道输入和输出仍然被跟踪和验证。
-
加快开发速度:大多数开发者花费时间验证他们的工作。我们见过开发者不懈地刷新浏览器、检查控制台日志和检查 DOM 元素。与其反复执行这些手动操作,不如简单地用框架将它们封装起来,由框架为您完成工作。
本章将探讨我们在开始新的可视化开发时喜欢使用的 Bootstrap 项目。项目中涵盖的概念包括:
-
项目结构
-
代码组织和可重用资产
-
单元测试
-
弹性代码库
代码组织和可重用资产
我们编写可重用和可测试 D3 代码的基础来自迈克·博斯托克的博客文章 Towards Reusable Charts,在 bost.ocks.org/mike/chart/
。其核心是尝试将图表实现为具有 getter
和 setter
方法的闭包。这使得代码更易于阅读和测试。实际上,在继续之前阅读这篇文章是个好主意,因为我们可以借鉴一些我们的职业经验并将这些概念进一步扩展。项目结构旨在实现几个目标。
项目结构
Bootstrap 项目包含以下文件和目录:
项目自带示例代码即可运行。为了看到这一效果,我们将启动示例。从提供的示例 Bootstrap 代码开始,首先安装所有依赖项(请注意,您只需执行此命令一次):
npm install
然后,为了查看可视化效果,执行以下操作:
node node_modules/http-server/bin/http-server
接下来,打开浏览器到http://localhost:8080
。你应该会看到一系列测试中基于随机数据的三个条形图在变化。请注意,如果你已经打开了之前的示例,你必须终止该进程才能运行这个,因为它们都使用了相同的端口。
要查看单元测试的工作情况,只需执行以下操作:
node_modules/karma/bin/karma start
你应该在终端中看到五个单元测试的摘要,以及一个持续运行的过程来监控你的项目:
INFO [karma]: Karma v0.12.21 server started at
http://localhost:9876/
INFO [launcher]: Starting browser Chrome
INFO [Chrome 37.0.2062 (Mac OS X 10.9.5)]: Connected on socket
goMqmrnZkxyz9nlpQHem with id 16699326Chrome 37.0.2062 (Mac OS X 10.9.5): Executed 5 of 5 SUCCESS
(0.018 secs / 0.013 secs)
我们将在本章后面解释如何为项目编写单元测试。要快速查看正在运行的测试,请查看spec/viz_spec.js
。
如果你更改此文件中的任何方法,你会注意到测试运行器会检测到代码中的更改并重新执行测试!这为开发者提供了一个极好的反馈循环,随着你继续完善你的工作。
探索代码目录
在本节中,我们将详细介绍每个文件并解释它在整体包中的重要性:
-
index.html
: 此文件是可视化的起点,当你将浏览器指向http://localhost:8080
时将自动启动。你会注意到该文件包含了许多在书中已经讨论过的点,例如加载适当的资源。随着我们遍历index.html
文件,我们将识别项目中使用的其他目录和文件。 -
main.css
:main.css
文件用于应用特定的 CSS 样式:你的可视化:
<link rel="stylesheet" type="text/css" href="main.css">
vendor
: 此目录包含我们在可视化中需要使用的所有外部库,并在index.html
文件的底部加载:
<script src="img/d3.min.js"></script>
<script src="img/topojson.v1.min.js"></script>
-
我们喜欢将这些保持到最小,以便尽可能少地对外部世界有依赖。在这种情况下,我们只使用核心 D3 库和 TopoJSON 来帮助我们进行 GeoJSON 编码。
-
scripts
: 这又是一个目录;我们在加载的文件中添加了一些新内容,以创建可视化:
<!-- A base function for setting up the SVG and container -->
<script src="img/base.js"></script>
<!-- The main visualization code -->
<script src="img/viz.js"></script>
-
base.js
脚本包含了一些在许多示例中重复使用的常见 D3 模式(例如,在具有预定义边距对象的<g>
图表区域中包含可视化,基于此边距对象计算高度和宽度的常用方法,以及一个方便的实用工具来查找现有容器并绑定数据)。base.js
脚本也是一个很好的位置来存放可重用代码。 -
viz.js
脚本是一个示例,它利用了《迈向可重用图表》中的许多概念,并从base.js
中获得了一些继承。viz.js
脚本是项目的核心,大部分可视化代码都将驻留于此。 -
factories
: 这也是一个目录。为了在我们的浏览器中展示我们的工作,我们需要一个脚本来生成一些数据,选择 DOM 中的元素,并启动可视化调用。这些脚本组织在factories
目录中。这个示例可以在viz_factory.js
文件中查看:
<!-- The script acts as a proxy to call the visualization
and draw it with sample data -->
<script src="img/viz_factory.js"></script>
spec
:你编写的测试以验证可视化代码中的方法,这里。将在本章后面提供详细示例。
其他管理文件
以下两个辅助文件用于 Bootstrap 项目的操作,这些文件很少需要任何修改:
-
karma.conf.js
:用于设置单元测试运行 -
package.json
:描述了要安装哪些 npm 包
编写可测试的代码
在创建可视化时需要考虑的因素有数十个。每个设计都将有其自己的一套独特的要求和配置能力。如果你基于 Mike Bostock 概述的可重复使用模式构建,你将有一个很好的框架开始。
当处理数据可视化时,我们将有一些形式的数据操作或逻辑必须应用于传入的数据。我们可以利用以下两个值得注意的最佳实践来测试和验证这些操作。它们将在以下部分中解释。
保持方法/函数小
小函数意味着低循环复杂度。这意味着每个函数中的逻辑分支更少,因此要测试的东西也更少。如果我们彻底且独立地测试每个简单函数,那么当我们将它们组合成更大的复杂计算时,出错的机会就会更少。一个好的指导原则是尽量保持方法在约 10 行代码左右。
防止副作用
这基本上意味着每个小函数不应在自身之外保存任何状态。尽可能限制全局变量的使用,并将每个函数视为以下过程:
-
数据到达。
-
对数据进行一些操作。
-
返回结果。
这样我们就可以轻松地独立测试每个函数,而不必担心它对程序全局状态的影响。
viz.js 的示例
为了在实践中学到这一点,让我们以scripts/viz.js
程序为例,作为创建可视化中数据操作函数可测试代码的模板。在这个例子中,我们将创建一组基于任意数据集利润的简单条形图。我们得到了数据中的销售额和成本;然而,我们需要通过从成本中减去销售额来确定用于可视化的利润。在这个虚构的例子中,我们需要几个小的辅助函数,如下所示:
-
一个函数,用于从原始数据集生成一个新的数据集,其中包含计算出的利润
-
一个函数,用于检索一个唯一类别数组,以应用于序数尺度
-
一个函数,用于确定最大利润值,以便构建我们输入域的上限
如果我们使用前面概述的最佳实践来创建这些函数并公开它们,我们就可以在隔离和独立的情况下测试它们。
让我们浏览一下脚本,看看它是如何协同工作的:
if (d3.charts === null || typeof(d3.charts) !== 'object')
{ d3.charts = {}; }
在这里,我们将为图表定义命名空间。在这个例子中,我们的图表可以通过d3.charts.viz
来实例化。如果带有图表属性的d3
对象不存在,或者它不是一个type
对象,则创建它,使用经典的功能继承来利用来自base
函数的通用模式:
d3.charts.viz = function () {
// Functional inheritance of common areas
var my = d3.ext.base();
一个方便的函数(见base.js
),可以快速将getters/setters
分配给遵循Towards Reusable Charts中模式的闭包,如下所示:
// Define getter/setter style accessors..
// defaults assigned
my.accessor('example', true);
我们在这个作用域级别使用svg
变量来维护在快速添加选择器时的状态。void 0
是一个初始化变量为 undefined 的安全方式:
// Data for Global Scope
var svg = void 0,
chart = void 0;
定义在整个可视化过程中将使用的 D3 实例函数:
// Declare D3 functions, also in instance scope
var x = d3.scale.linear(),
y = d3.scale.ordinal();
以下函数代表了与外部世界的主要接口。还有一套在 D3 可视化中常见的前置函数。SVG 容器被设置为可以轻松地在选择器中查找现有的 SVG 容器并重新绑定数据的方式。这使得在后续调用新数据时重新绘制变得更加容易:
my.draw = function(selection) {
selection.each(function(data) {
// code in base/scripts.js
// resuable way of dealing with margins
svg = my.setupSVG(this);
chart = my.setupChart(svg);
// Create the visualization
my.chart(data);
});
};
// main method for drawing the viz
my.chart = function(data) {
var chartData = my.profit(data);
x.domain([0, my.profitMax(chartData)])
.range([0,my.w()]);
y.domain(my.categories(chartData))
.rangeRoundBands([0, my.h()], 0.2);
var boxes = chart.selectAll('.box').data(chartData);
// Enter
boxes.enter().append('rect')
.attr('class', 'box')
.attr('fill', 'steelblue');
// Update
boxes.transition().duration(1000)
.attr('x', 0)
.attr('y', function(d) { return y(d.category) })
.attr('width', function(d) { return x(d.profit) })
.attr('height', y.rangeBand())
// Exit
boxes.exit().remove();
};
注意,chart
函数依赖于几个辅助函数(如下面的代码行所示)来处理数据。它也被编写成可以利用 enter/update/exit 模式的方式:
// Example function to create profit.
my.profit = function(data) {
return data.map(function(d) {
d.profit = parseFloat(d.sales) - parseFloat(d.cost);
return d;
});
};
此函数用于创建一个新的数据结构,其中分配了利润。请注意,它接受一个数据数组作为参数,并返回一个新构造的数组,其中添加了利润属性。现在,此函数通过viz().profit(data)
公开外部,可以轻松地进行测试。它不会更改任何外部全局变量。它只是输入数据和新数据:
my.categories = function(data) {
return data.map(function(d) {
return d.category;
});
};
这与my.profit(data)
的模式完全相同。我们将接受数据结构作为输入,并返回一个新的数据结构,即所有类别的数组。在前面的代码行中,你看到了它是如何被用来创建输入域的。
my.profitMax = function(data) {
return d3.max(data, function(d) { return d.profit; });
};
再次强调,这是一个简单的函数,用于接收数据,计算最大值,并返回该最大值。使用d3.charts.viz().profitMax(data)
非常容易进行测试和验证?
return my;
};
单元测试
既然我们已经用可测试的方式编写了代码库,让我们自动化这些测试,这样我们就不必手动执行它们,可以轻松地继续编码和重构。
如果你查看spec/viz_spec.js
文件,你将注意到单元测试时的一些常见模式。以下代码是用一个名为 Jasmine 的 JavaScript 单元测试框架编写的,并利用 Karma 来执行测试。你可以在jasmine.github.io/1.3/introduction.html
了解更多关于 Jasmine 语法、断言和其他功能的信息。
Bootstrap 项目包含了你快速开始测试所需的一切。
第一步是使用以下代码行启动我们的 Karma 测试运行器:
node_modules/karma/bin/karma start
这个运行器将监视viz.js
文件或viz_spec.js
文件的每个编辑。如果检测到任何更改,它将自动重新运行每个测试套件,并在控制台上提供输出。如果所有测试都通过,则输出将全部为绿色。如果有任何失败,你将收到一个红色的警告消息:
'use strict';
describe('Visualization: Stacked', function () {
var viz;
var data = [
{"category": "gold", "cost": "10", "sales": "60"},
{"category": "white", "cost": "20", "sales": "30"},
{"category": "black", "cost": "100", "sales": "140"}
];
创建一些测试数据来测试你的 D3 数据操作函数。前面的describe
语法定义了你即将执行的测试框架:
beforeEach(function() {
viz = d3.charts.viz()
.height(600)
.width(900)
.margin({top: 10, right: 10, bottom: 10, left: 10});
});
在每次测试运行之前,创建一个具有一些默认设置器的 D3 可视化实例:
it ('sets the profit', function() {
var profits = viz.profit(data);
expect(profits.length).toBe(3);
expect(profits[0].profit).toBe(50)
});
这是我们第一个测试用例!在这个测试中,我们断言从测试数据中得到了一个新的数组,但增加了额外的利润属性。请记住,我们创建这个函数是为了没有副作用,并且是一个小的单元工作。我们将通过这种易于测试的方法收获我们的劳动成果。就像我们之前做的那样,我们现在将测试类别列表:
it ('returns a list of all categories', function() {
var categories = viz.categories(data);
expect(categories.length).toBe(3);
expect(categories).toEqual([ 'gold', 'white', 'black' ]);
});
按如下方式计算最大利润:
it ('calculates the profit max', function() {
var profits = viz.profit(data);
expect(viz.profitMax(profits)).toEqual(50);
});
以下是一些额外的示例测试,以验证height
/width
(考虑到边距)是否在base.js
函数中正常工作:
it ('calculates the height of the chart box', function() {
expect(viz.h()).toBe(580);
viz.height(700); // change the height
viz.margin({top: 20, right: 10, bottom: 10, left: 10})
expect(viz.h()).toBe(670);
});
it ('calculates the width of the chart box', function() {
expect(viz.w()).toBe(880);
viz.height(700); // change the height
viz.margin({top: 10, right: 10, bottom: 10, left: 20})
expect(viz.w()).toBe(870);
});
作为实验,尝试添加新的测试用例或编辑现有的测试用例。观察测试运行器报告不同的结果。
创建健壮的可视化代码
我们想确保我们的可视化可以最小化调用程序的努力来响应数据的变化。测试不同数据排列并确保可视化相应反应的一种方法是通过随机创建示例数据,多次调用可视化代码,并观察结果。这些操作在factories
目录中处理。让我们以viz_factory.js
文件为例:
(function() {
var viz = d3.charts.viz();
创建一个变量以存储具有getters
和setters
闭包的函数。在这个例子中,我们将使用一个匿名函数作为包装器来执行代码。这可以防止与其他 JavaScript 代码冲突,并确保我们的可视化在受保护的环境中能够正常工作:
var rand = function() {
return Math.floor((Math.random() * 10) + 1)
};
一个简单的辅助函数,用于生成介于 1 到 10 之间的随机数,如下所示:
var data = function() {
return [1,2,3].map(function(d,i) {
var cost = rand();
var sales = rand();
return {
category: 'category-'+i,
cost: cost,
sales: cost + sales
};
});
};
基于随机数生成一个假数据集:
d3.select("#chart").datum(data()).call(viz.draw);
使用这些代码行首次绘制可视化:
var id = setInterval(function() {
var d = data();
console.log('data:', d);
d3.select("#chart").datum(d).call(viz.draw);
}, 2000);
setTimeout(function() {
clearInterval(id);
}, 10000);
设置一个 10 秒的定时器,并在迭代中将新数据绑定到可视化上。预期的行为是可视化将在每次调用时重新绘制自己。注意将新数据传递给可视化是多么简单。它只是一个带有新数据集的简单选择器。我们已经以这种方式构建了可重用的可视化代码,使其知道如何适当地做出反应。
要查看实际效果,只需启动http-server
,如下所示:
node_modules/http-server/bin/http-server
现在,访问http://localhost:8080
。
添加一个新的测试用例
如果我们改变数组中的数据集数量会发生什么?为了测试这一点,让我们添加一个新的辅助函数(称为set()
),它可以随机生成一个包含 1 到 10 之间随机元素数量的新数据集:
var set = function() {
var k = rand();
var d = [];
for (var i = 1; i < k; i++) {
d.push[i];
};
return d;
};
稍微修改一下data
函数。我们将打印到控制台以验证它是否正常工作:
var data = function() {
var d = set();
console.log('d', d);
return d.map(function(d,i) {
var cost = rand();
var sales = rand();
return {
category: 'category-'+i,
cost: cost,
sales: cost + sales
};
});
};
现在,如果我们再次查看http://localhost:8080
,我们可以看到,即使是有随机数量的数据,可视化也能正常工作。
摘要
在本章中,我们描述了帮助测试您的 D3 代码库并保持项目生命周期内健康的技巧。我们还逐步通过一个 Bootstrap 项目来帮助您开始使用这些示例,并查看了一种构建您工作的方法。
我们的建议基于多年的经验和许多使用 D3 完成的项目。我们强烈建议您遵循良好的软件模式并专注于测试;这将使您能够完善您的技艺。现在,质量掌握在您的手中。
第八章:使用 Canvas 和 D3 绘图
到目前为止,您主要使用 D3 通过 SVG 和 HTML 元素来渲染您的可视化。在本节中,您将学习如何使用 HTML5 Canvas 来绘制和动画化您的可视化。Canvas 可以作为 SVG 的替代品,尤其是在您想在屏幕上渲染更多元素时。您将了解 Canvas 是什么以及它与 SVG 的比较。您将学习如何使用 Canvas 绘制和动画化,以及如何与 D3 一起使用。
在覆盖了基础知识之后,我们将首先使用 SVG,然后使用 Canvas 来可视化飞行路径,以便实际对比和比较两种渲染方法。首先,这将让您对 Canvas 作为 SVG 的替代品有实际的理解。其次,它将展示 Canvas 如何解决您在用 SVG 同时动画化数千个点时可能遇到的问题,因为浏览器在绘制数千个元素的单一图片方面比在内存中构建、存储和维护数千个元素的树结构要快得多。
本章我们将涵盖以下主题:
-
Canvas 及其工具概述:Canvas 上下文
-
如何使用工具绘制 Canvas
-
如何使用 Canvas 动画化绘图
-
如何将 D3 生命周期应用于 Canvas 绘图的各个部分
介绍 Canvas
在开始使用 Canvas 绘图之前,让我们简要地看一下其概念——这个心理模型将帮助您接近、规划和编写您的应用程序。Canvas 在其物质形式上是一个单一的 HTML5 元素。它字面上是一个可以绘制的空白画布。对于实际的绘图,您使用Canvas 上下文——Canvas API。上下文可以被视为您的工具箱,可以使用 JavaScript 进行操作。
您可以将 Canvas 元素与根 SVG 元素进行比较,因为它们都包含绘图的全部部分。然而,关键的区别在于 SVG(像 HTML 一样)在保留模式下操作。浏览器保留了一个列表,其中包含在文档对象模型(DOM)中绘制到 SVG(或 HTML)画布上的所有对象——您的 web 应用的场景图。这使得您的绘图几乎像物质一样。您产生一个对象列表,通过代码更改样式和属性,并且您可以在任何时候引用这些元素。您可以更改它们的位置,在 DOM 中上下移动它们,并且对于交互来说非常重要——您可以轻松地附加和移除事件监听器。
与之相反,Canvas 以立即模式运行。你使用 Canvas 绘制的任何内容都会立即发生,并作为图像保留在画布上。Canvas 中的图像是位图,由像素矩阵或网格组成的数字图像。当你使用 Canvas 绘制时,你使用工具准备每个像素(或更确切地说,指定像素区域)的属性,然后在画布上绘制它们。如果你想改变图像中一个、几个或所有像素的颜色,你需要移除整个图像并生成一个新的图像。与 SVG 不同,你不能回到你想更改的像素,因为它不是以文档树或类似的形式在内存中表示,而是一次性烧录到屏幕上。但别担心,你仍然有 Canvas 上下文,它代表了你工具的状态,间接地代表了绘图本身。
你可以将 Canvas 想象成一幅画,而 SVG 或 HTML 则像乐高结构。前者在表示上是静态的。如果你画了一个站在桥上尖叫的人,你不能只是转动他的头。你需要画第二幅画来精确表达这一点。如果你用乐高搭建同样的场景,你可以抓起头部转动它,就像这样:
立即 Canvas 和保留的 SVG 的戏剧化对比(SVG 图像由 Marco Pece 提供,www.udronotto.it)
考虑到使用 Canvas 进行动画可能需要投入的大量工作,这可能会显得有些繁琐。不仅需要承受绘制这么多图片的心理压力,还需要计算能力来快速连续地重绘一切。但是,正如你将在接下来的章节中看到的,有一些简单的模式可以使 Canvas 动画化和交互式。
使用 Canvas 绘制
在我们回到 SVG 和 Canvas 的更详细比较,并看到具体的使用场景之前,让我们学习如何使用 Canvas 绘制。我们将从一个非常简单的例子开始,以了解绘制 Canvas 时涉及到的三个主要步骤。然后你将绘制一系列形状,以熟悉其工具箱。
作为旁注,当你在查看代码示例时,我强烈建议你使用最新的 Chrome 浏览器。所有代码都在现代浏览器上进行了测试,但主要考虑了 Chrome,因此它将是工作最安全的浏览器。
每个 Canvas 视觉的三个绘图步骤
我们可以将 Canvas 绘图分解为三个简单的步骤:
-
创建画布及其上下文。
-
配置上下文。
-
渲染生成的位图。
在你的 DOM 中挂载画布,你需要在 HTML 中创建一个<canvas>
元素:
<canvas id=”main-canvas” width = “600” height=”400”></canvas>
它看起来会空空如也,正如预期的那样:
一个空的 Canvas 元素
这将是你在 DOM 中看到的画布的全部内容。所有其他操作将通过 JavaScript 中的 Canvas 上下文来完成。
如果你想在画布上绘制一个矩形,比如使用royalblue
颜色,你将直接进入 JavaScript 编写代码,而无需回到 HTML:
var canvas = d3.select('#main-canvas').node();
var context = canvas.getContext('2d');
context.fillStyle = 'royalblue';
context.fillRect(50, 50, 200, 100);
您可以在此章节的所有代码github.com/larsvers/Learning-D3.js-4-Mapping
中找到。
让我们一步一步地过一遍我们的第一步:
- 首先,您需要在变量中引用画布。我们将使用
d3.select()
来完成此操作。由于您需要引用画布元素本身,而不是选择,您应该使用 D3 的selection.node()
方法来获取元素本身。下一行引用了绘制上下文到这个特定的画布元素。上下文包含了您可以使用来绘制的所有工具。您可以通过console.log('context')
来查看上下文对象:
显示所有属性的上下文对象
上下文在内部被称为CanvasRenderingContext2D
,但我们将简单地称之为context
。它包含了您可以用来绘制视觉的所有属性。如果您愿意,也可以扩展__proto__
对象,这将显示您可用的所有方法。我们将随着解释关键属性和方法,而不会逐个深入。上下文对象的重要之处在于理解有一个对象在帮助您构建绘图。它始终在您身边,允许您使用其方法和更改其属性。
了解上下文 API、其属性和方法的一个好地方是 Mozilla 开发者网络上的文档,网址为developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D
。
在这一点上,您已经有了可以绘制的画布和绘图工具,但您还没有绘制任何东西。
-
在第二步中,您准备绘图。您配置上下文以产生所需的绘图。示例故意很简单,因为唯一的配置是将我们尚未存在的对象的填充设置为
royalblue
。请注意,context.fillStyle
是 Canvas 上下文的一个属性,而不是一个方法。就像您是一个画家,告诉您的画笔盒您想要为下一个绘制的对象使用什么颜色一样。 -
第三步会产生以下图像。
context.fillRect()
接受四个参数:矩形的起始点的x
和y
位置以及width
和height
。Canvas - 类似于 SVG - 使用一个以左上角为原点 0,0 的笛卡尔坐标系,向右和向下增加。所有值都是以像素为单位:
一个非常皇家蓝色的画布矩形。
这个矩形不在 DOM 中。您可以在 JavaScript 中看到其配置以及绘制到 DOM 中的画布,但没有<rect>
元素或类似的内容我们可以引用。再次提醒,不要担心,我们将在接下来的两章中巧妙地重新定位它。
在浏览器中查看此步骤:larsvers.github.io/learning-d3-mapping-8-1
。代码示例08_01.html。
在每个步骤的结尾,你将在靠近相关图像的信息框中找到两个链接。第一个链接将带你到一个可以在浏览器中查看的此步骤的工作实现。第二个代码示例链接将带你到完整的代码。如果你在读印刷版,你可以在github.com/larsvers/Learning-D3.js-4-Mapping
的相关章节中找到所有代码示例。
你已经看到了在 Canvas 中制作几乎所有绘图的基本步骤。这些关键概念步骤将帮助你接近任何 Canvas 绘图。现在让我们继续绘制。
使用 Canvas 绘制各种形状
让我们添加一些其他基本几何形状或图形原语到我们的画布中。由于它们是所有可视绘制的构建块,一些练习将对我们有益。以下是我们要绘制的:
一座房子和一棵树。或者一个三角形旁边的路径和圆下的三个矩形。
在浏览器中查看此步骤:larsvers.github.io/learning-d3-mapping-8-2
。代码示例08_02.html。
你可以在 JavaScript 控制台右侧看到代码,在我们逐步执行它之前,让我们注意一些一般观察。首先,每一行都以context
开头。Canvas 上下文确实是我们的绘图开始的地方。其次,Canvas 代码是以过程式风格编写的。这对初学者来说可能是一个好处,因为它是线性的。没有回调,没有嵌套元素结构,只有一条执行线。这种线性也将扩展到时间,一旦你开始动画画布。你首先写第一帧,然后改变场景,然后写第二帧。就像翻书一样简单。让我们逐步查看代码,看看如何详细创建这些元素。我建议的第一件事是给画布添加一个边框。由于画布元素是一个 HTML 元素,你可以用 CSS 来样式化它,但在这里我们使用 JavaScript 来展示画布本身的两个属性:width
和height
:
context.strokeStyle = '#CCCCCC';
context.strokeRect(0, 0, canvas.width, canvas.height);
width
和height
是画布元素拥有的唯一属性。我们在这里使用它们来读取元素的值,然而,它们是可读和可写的。这很好,因为当你想要在动画过程中调整画布大小时,你可以通过编程方式更改画布大小。接下来,我们构建我们的平顶蓝色房子:
context.fillStyle = 'royalblue';
context.fillRect(50, 150, 200, 100);
这里没有太多可看的,我们之前已经做过。门也不会让你感到费劲,因为它和房子一样,只是颜色不同:
context.fillStyle = 'rgba(255, 255, 255, 0.9)';
context.fillRect(60, 190, 40, 60);
然而,我们使用不同的方法来描述颜色。你可以使用所有 CSS 颜色概念,如命名颜色值和十六进制颜色值,以及 rgb()
、rgba()
、hsl()
和 hsla()
颜色方法。窗口使用 context.translate()
放置得略有不同:
context.save();
context.translate(140, 190);
context.fillRect(0, 0, 60, 30);
context.restore();
在这种情况下,我们移动的不是矩形,而是整个坐标系!translate()
方法接受两个参数:你想要移动坐标系到的 x 和 y 位置。你已经从使用 transform, translate(x,y)
的例子中知道了这个概念,这通常用于在 D3 中移动 svg:g
元素并创建它们自己的坐标系。然而,当应用于 svg:g
元素时,变换后的坐标系适用于嵌套在 g 元素内的所有对象。如上所述,g
元素及其子元素,包括其坐标系,作为场景图表示保留在 DOM 中。在 Canvas 中,我们不能将此信息移动到我们绘图的表示中——没有这样的东西。这取决于你确保只有你想在另一个坐标系上显示的元素才会这样做。记住,当我们谈论 Canvas 代码的进程式风格时?这正是我们在这里必须牢记的。当我们更改 context
中的内容时,它将一直持续到我们再次更改它。要更改坐标系,我们可以选择将其移动到我们想要的位置,然后再移动回来,如下所示:
context.translate(140, 190);
context.fillRect(0, 0, 60, 30);
context.translate(-140, -190);
但我们更倾向于使用通用的 context.save()
和 context.restore()
方法。context.save()
在代码的这个点保存状态并将其推入 栈,而 context.restore()
则从栈中弹出最后保存的状态并恢复上下文的先前状态。如果你之前没有遇到过栈,这里有一张图片解释了它是如何工作的:
数据堆叠的塔楼。
简而言之,栈是一种类似于数组或对象的数据类型。然而,栈仅限于两种操作:在栈顶添加元素(push)和从栈顶移除元素(pop)。它就像一座砖塔。这种对我们应用程序状态的维护是 Canvas 的一个定义性特征,也是与 SVG 的一个关键区别。
接下来,我们给房子加上一个三角形的屋顶。Canvas 中没有 triangle()
函数,所以你需要绘制一个路径:
context.beginPath();
context.moveTo(50, 150);
context.lineTo(250, 150);
context.lineTo(50+200/2, 100); // you can use calculations as inputs!
context.closePath();
context.fillStyle = '#A52A2A';
context.fill();
最后,我们绘制树。树有一个棕色的茎,你可以将其实现为直线路径,以及一个绿色的树顶,你可以将其绘制为绿色圆圈:
context.beginPath();
context.lineWidth = 10;
context.strokeStyle = 'brown'
context.moveTo(300, 250);
context.lineTo(300, 200);
context.stroke();
context.beginPath();
context.fillStyle = 'green';
context.arc(300, 175, 25, 0, Math.PI * 2);
context.fill();
这里有两点需要注意。首先,所有路径代码块都由 beginPath()
和 stroke()
(茎)或 fill()
(屋顶和树顶)括起来:
context.beginPath();
// configure your path here
context.stroke();
context.beginPath();
// configure your path here
context.fill();
beginPath()
表示开始绘制新路径并移除所有当前路径(或子路径)实现。stroke()
和fill()
表示路径的结束,将在屏幕上生成路径。fill()
将使用设置的fillStyle
颜色填充路径主体,而stroke()
将仅使用设置的strokeStyle()
方法绘制路径轮廓。每次你绘制路径时,你都需要这些起始和结束方法。实际上,每次你绘制任何东西时,你都需要它们。fillRect()
或strokeRect()
,如之前所用的,只是开始路径、绘制路径和填充或描边路径的包装器。你可能已经注意到,我们只绘制了三角形屋顶的两边,然后使用了closePath()
来连接路径的当前终点和起点。fill()
方法也会为你关闭路径,但明确这样做更为彻底,性能更好,并且有助于你代码的读者(包括你自己)。第二件事要注意的是,即使是圆形也是一个路径。事实上,Canvas API 提供的唯一超出路径的原始形状是矩形。SVG 简化了<rect>
、<circle>
、<ellipse>
、<line>
、<polyline>
、<polygon>
和<path>
的使用,而 Canvas 只提供路径和矩形。然而,使用路径绘制形状很快就会变得常规。虽然没有预定义的圆形,但有arc()
和arcTo()
方法,它们几乎为你完成了圆形绘制。你只需要给它添加颜色,并将其包裹在路径的起始和结束方法中。arc()
接受五个参数,即x和y位置、半径、弧的起始和结束角度。这两个角度都是以弧度测量的。
弧度?一个弧度等于57.3度。弧度是角度的另一种度量单位。数学家们非常喜欢它,因为它们在几何计算中非常有意义。要得到一个弧度,你取圆的半径并将其绕圆周缠绕——如果你能想象半径线是可弯曲的:
如何得到一个弧度
它们的数学优势是它们可以直接从圆的半径中导出。更美妙的是,半圆(如 180 度)正好是PI弧度。因此,一个完整的圆等于2 * PI弧度。
度数可能对你来说更有意义。那很好。如果你想在屏幕上移动对象,它们也更有意义。你可以通过使用以下公式轻松地在弧度和度之间进行转换:(PI / 180) * degrees**。PI是弧度的一半圆,180 是度数的一半圆。通过将它们相除,你将一个度数表示为弧度,等于 0.0175。将任何你想要的度数乘以 0.0175,并将结果用作弧度。
好的!我们已经画出了一个带有房子的风景——那太棒了。Canvas 当然还有更多内容,但通过遵循这些简单的步骤,你已经学到了很多。你学习了使用 Canvas 绘图的概念以及编写过程式代码的含义。你看到了如何使用 Canvas 绘制单个形状,如何使用平移变换移动单个对象,以及 Canvas 中每个形状的原子单位是路径。现在,让我们提高难度,在用 D3 方法之前,先以 Canvas 方式动画化我们的风景。
以 Canvas 方式动画化
Canvas 的一个关键优势是动画。当浏览器必须努力重新计算和重新渲染 DOM 中保留的许多元素时,它相对轻松地重新绘制位图图像。在下一节中,你将学习如何使用 Canvas 进行动画。让我们首先看看如何以纯、原生的 Canvas 方式做到这一点。之后,让我们看看我们是否可以使用 D3 的过渡和生命周期进入-更新-退出模式来帮助我们进行动画。这两种方法在用 D3 和 Canvas 构建可视化时都将非常有帮助,因为你将能够为你的想法选择正确的技术或补充这两种方法。
以 Canvas 方式动画化
让我们回到我们的房子,并测试它的屋顶是否下雨:
真的在下雨。
在浏览器中查看此步骤:larsvers.github.io/learning-d3-mapping-8-3
。代码示例 08_03.html。
在静态图像中很难看到,但在浏览器中查看时,蓝色的小点实际上是在向下移动。它们还以不同的速度移动,这使得雨看起来更加逼真。
获得一般概述
在抽象的最高级别,我们的代码如下所示:
var canvas = d3.select('#main-canvas').node(); // set up
var context = canvas.getContext('2d');
var rain = { } // produce data
d3.interval(function() {
update(); // update/process the data
animate(); // re-draw the canvas
}, 10);
在设置好画布之后,你将生成一些数据——雨滴。然后你将进入一个循环,在这个循环中,你将更新下一场景的数据,然后绘制它。在我们的例子中,update()
函数会改变雨滴的位置,而 animate()
函数将清除当前图像并使用更新后的雨滴位置绘制一个新的图像。
这个循环(或者至少是一个非常相似的版本)被称为 游戏循环,因为它在 Canvas 游戏编程中被使用。你处理玩家的输入,相应地更新游戏数据,并绘制新的场景。我们将很快习惯这种模式。现在,让我们看看细节。
准备雨数据
你正在处理的是雨滴元素。在我们更新或动画化单个雨滴之前,我们首先生成它们。我们正在构建一个所谓的 对象字面量模块,名为 rain
(它是一个 对象字面量),它知道如何生成雨滴,并且将单个雨滴保存在一个名为 items
的数组中。它看起来会是这样:
var rain = {
items: [],
maxDrops: 200,
getDrop: function() {
var obj = {};
obj.xStart = Math.floor(Math.random() * canvas.width);
obj.yStart = Math.floor(Math.random() * -canvas.height);
obj.x = null;
obj.y = null;
obj.speed = Math.round(Math.random() * 2) + 5;
return obj;
},
updateDrop: // see below
}
rain
对象由这个目前为空的数组 items
组成,该数组将保存我们产生的所有雨滴对象,还有一个名为 maxDrops
的变量,将雨滴数量(items
的长度)限制在本例中的 200 个。这可以被认为是小雨。如果你想淹没树木或测试应用程序的性能,可以将这个数字提高到更高的数值。由于我们喜欢这棵树,并且将在接下来的示例中测试性能,所以现在 200 个就足够了。
两个函数将帮助生成和更新雨滴。getDrop()
将起始位置分配在画布上方看不见的地方,以及空的 x
和 y
位置,这些位置将在更新时被填充。你还定义了雨滴的速度,它可以取五到七之间的值。速度将是雨滴在每次更新中向前移动的像素数。一个较低的数值会产生慢雨,而一个较高的数值会产生快雨。
updateDrop()
函数可以在我们想要更新雨滴位置的情况下调用。现在让我们这样做。
更新每个雨滴
网站加载后,将使用 d3.interval
函数启动操作,该函数每 10 毫秒调用它包含的所有函数。首先,它将调用 update()
,该函数返回一个对象数组。每个对象都是一个雨滴,最显著的特征是任意的 x
和 y
位置。这就是它的样子:
function update() {
if (!rain.items.length) {
d3.range(rain.maxDrops).forEach(function(el) {
var drop = rain.getDrop(el);
rain.updateDrop(drop);
rain.items.push(drop);
});
} else {
rain.items.forEach(function(el) {
rain.updateDrop(el);
});
}
}
第一次调用 update()
时,它会生成一个雨滴,更新其位置,并将其推入雨滴项目数组。在其他任何情况下,它只是更新雨滴的位置。我们使用 d3.range
作为这个循环的便捷方法。它接受一个整数作为输入,并返回一个从 0 开始的整数数组,长度等于你传入的数字。在这里,它帮助我们创建与 maxDrops
中指定的数量相等的雨滴。
然后我们使用之前开始描述的 updateDrop()
函数更新雨滴的位置:
updateDrop: function(drop) {
drop.x = drop.x === null ? drop.xStart : drop.x;
drop.y = drop.y === null ? drop.yStart : drop.y + drop.speed;
drop.y = drop.y > canvas.height ? drop.yStart : drop.y;
}
如果雨滴的 x
和 y
位置还不存在(如果它们是 null
),我们将 xStart
或 yStart
的值分配给它。如果已经存在,我们保持 x
位置不变,因为没有雨滴会移动到任何一边,我们将 y
位置向下移动一定的速度。这将使雨滴每 10 毫秒向下移动五到七像素。最后一行将每个雨滴回收。一旦它到达底部画布的最终边界,我们只需将其 y
值设置为初始的 yStart
值。
一帧一帧地绘制
在我们的 d3.interval
循环中,update()
已经运行,我们有了所有雨滴的位置。接下来,我们将处理它们的绘制。如果我们有一个 DOM,我们将与无处不在的 200 个 SVG 圆圈交互,并请求它们友好地向下移动一点。但我们是生成一个静态图像,我们只能绘制而不能更改。所以,我们绘制。就像翻书一样,我们丢弃旧图像并绘制一个新图像。让我们重复这个过程。每次我们想在画布上移动某个东西时,我们移除****旧图像并绘制一个带有新位置的新图像。
这很简单:
function animate() {
context.clearRect(0, 0, canvas.width, canvas.height);
drawScene();
rain.items.forEach(function(el) {
circle(context, el.x, el.y, 1.5, 'blue');
});
}
animate()
使用上下文的clearRect()
函数,正如其名。你传递给它你想清除的区域——在我们的案例中是整个画布——然后它会将其清除。你也可以填充一个白色矩形或更改canvas.width
和canvas.height
的值,但clearRect()
比第一种方法更快,比第二种方法更清晰。
接下来,你运行drawScene()
函数,它绘制我们的场景:房子和树。这就是你在上一节中构建的内容,只是封装在一个恰如其分的函数中。
最后,我们将每个雨滴绘制到画布上。你问“circle()
是什么?”?这是一个构建视觉原语的帮助函数——在我们的案例中是一个圆。它已经被添加到代码的顶部:
function circle(ctx, x, y, r, color) {
ctx.beginPath();
ctx.fillStyle = color;
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.fill();
}
两个主要函数update()
和animate()
会一直运行,直到你的浏览器标签页的会话结束;这可能意味着一段时间内会有坏天气。
画布和 D3
D3 为在网络上生成数据可视化提供了无与伦比的功能。你可能已经意识到了这一点,因为你正在阅读这本书。D3 提供的一个重要部分是其数据注入元素在屏幕上如何演化的模型。它对每个元素的生命周期有特定的思考方式。
在实践中,你将数据注入到一个尚不存在的 DOM 中,D3 会根据你注入的数据创建你选择的新元素,通常每个数据点一个元素。如果你想将新数据注入到 DOM 中,你可以这样做,D3 会识别哪些元素需要新创建,哪些元素可以保留,以及哪些元素应该收拾行李离开屏幕。这样,你可以表示一个常见数据流的三个不同状态:进入数据,更新数据,和退出数据。然后你可以获取这些选择,使用 D3 内置的插值器操作它们的视觉属性,并在它们之间进行过渡。
这与在 DOM 中表现出来的保留 SVG 元素很好地配合。然而,我们在画布中没有 DOM 元素,因此必须稍微聪明一点来生成它们。让我们看看我们如何遵循 D3 的生命周期模型,同时使用 D3 的过渡来在这三种状态之间进行插值。
概览我们的实验
我们仍然会让雨在我们的小房子及其树上落下,但现在雨会遵循其自然过程——可以说是雨的生命周期。它将以云的形式进入,随着雨移动到地面上一个大水坑中而更新,最后它将退出,将水坑变成一片郁郁葱葱的草地:
雨的进入、更新和退出
在浏览器中查看此步骤:larsvers.github.io/learning-d3-mapping-8-4b
。代码示例08_04b.html。
正如你所见,右侧有按钮允许你控制三个状态变化。
代码的结构将与之前的纯 Canvas 动画类似。从概念上讲,你首先计算元素(雨滴)的位置,然后绘制。然而,我们实现这种交互的方式完全是通过 D3。为了提前揭开盖子,你将数据绑定到虚拟 DOM 元素上。这些 DOM 元素是“虚拟”的。由于 Canvas 没有 DOM,你可以在内存中创建一个基本的 DOM 结构,这样我们就可以使用 D3 的选择、数据绑定以及随后的 Enter-Update-Exit API。此外,应用程序还将有按钮交互,根据更改的数据更改元素的状态。我们已经讨论了 Canvas 设置以及数据准备,所以让我们专注于本节的核心新特性,即数据绑定和绘制!
数据
话虽如此,我们应该简要地看一下我们将使用名为getRainData()
的函数生成的数据。它将给我们 2,500 个雨滴(这次是暴雨),这些雨滴与之前的示例略有不同:
使用纯 Canvas 方式与使用 D3-and-Canvas 方式创建的雨滴
关键区别在于,对于 D3 雨滴版本,你不需要速度,因为我们计划让 D3 的过渡实现动画。此外,D3 和 Canvas雨滴具有一组状态属性,而纯 Canvas雨滴只有起始位置和当前位置。
更新每个雨滴
数据已经就绪,现在是时候让它动起来了。在纯 Canvas 示例中,你是在画布上绘制每个点,然后计算新点,将前一个点的位置增加五个像素,移除旧图像,并使用高级滴落绘制新图像。你自己转换了这些点。
使用 D3 的过渡方法不同之处在于我们不需要计算新位置,D3 会为我们做这件事。你将数据绑定到选择上,请求 D3 过渡值,在过渡期间,你将重新绘制画布,直到过渡完成。在顶层,你只需要两个函数来完成这个任务:
databind(data) {
// Bind data to custom elements.
}
draw() {
// Draw the elements on the canvas.
}
这非常直接。
绑定数据
然而,D3 在选择上实现过渡,而我们还没有选择。一个 D3 选择是一个绑定数据的元素。使用 D3,你选择一个 DOM 元素,通常是 SVG,将其数据绑定到它上面,你就有了具有所有奇妙方法的选择:显式的enter()
和exit()
方法,由data()
触发的隐式update()
方法,以及transition()
及其辅助方法duration()
和delay()
,它们控制过渡。
要创建选择,你只需创建类似 DOM 的元素,而且很棒的是,你不需要实体的 DOM 来做这件事。你可以在内存中创建它们。下面是如何做的:
var customBase = document.createElement('custom')
var custom = d3.select(customBase);
你可以将 customBase
想象成一个根 SVG 元素的替代品,而 custom
则是一个完整的 D3 选择。在你的基础搭建好之后,你可以使用 databind()
函数将数据绑定到你的自定义元素上:
function databind(data) { }
首先,我们将传递给 databind()
函数的数据进行连接:
var join = custom.selectAll('custom.drop')
.data(data, function(d) { return d.currentIndex; });
作为 data
第二个参数传入的关键函数在这个情况下并不是必需的,但作为良好实践,它使得连接变得明确,并且可能带来性能上的好处。
现在你创建你的选择状态。enter
选择是第一个:
var enter = join
.enter().append('custom')
.attr('class', 'drop')
.attr('cx', function(d) { return d.xCloud; })
.attr('cy', function(d) { return d.yCloud; })
.attr('r', function(d) { return d.radiusCloud; })
.attr('fillStyle', 'rgba(0, 0, 255, 0')
.transition().delay(function(d, i) { return i * 2; })
.attr('fillStyle', 'rgba(0, 0, 255, 0.2');
关于设置 fillStyle
属性的最后一行,有两点需要注意。当你使用 SVG 时,最后一行会是:
.style('color', 'rgba(0, 0, 255, 0.2')
但在 Canvas 中,你使用 .attr()
。为什么?你在这里的主要兴趣是找到一种无痛苦的方式来传输一些元素特定的信息。在这里,你希望将颜色字符串从 databind()
传输到 draw()
函数。你只是将元素作为一个容器,将你的数据传输到它被渲染到画布上的地方。
这是一个非常重要的区别:当使用 SVG 或 HTML 时,你可以将数据绑定到元素上,并在一步中绘制或应用样式到元素。在 Canvas 中,你需要两步。首先,绑定数据,然后绘制数据。在绑定时不能对元素进行样式设置。它们只存在于内存中,Canvas 不能通过 CSS 样式属性进行样式设置,这正是你使用 .style()
时访问的内容。
让我们快速看一下在创建并将 enter
选择附加到 customBase
元素之后,customBase
元素看起来是什么样子:
我们的自定义根元素显示了 30 个雨滴的进入状态。
结构看起来很熟悉,不是吗?
接下来,你定义 update
选择,最后是 exit
选择:
var update = join
.transition()
.duration(function() { return Math.random() * 1000 + 900; })
.delay(function(d,i) { return (i / data.length) * dur; })
.ease(d3.easeLinear)
.attr('cx', function(d) { return d.xPuddle; })
.attr('cy', function(d) { return d.yPuddle; })
.attr('r', function(d) { return d.radiusPuddle; })
.attr('fillStyle', '#0000ff');
var exit = join
.exit().transition()
.duration(dur)
.delay(function(d,i) { return i ; })
.attr('r', function(d) { return d.radiusGrass; })
.attr('fillStyle', '#01A611');
那就是所有放入 databind()
中的内容。
绘制数据
现在你需要编写 draw()
函数来获取屏幕上的元素。在这里我们只是做个笔记,目前还没有发生任何事情。你还没有调用 databind()
,因为你需要先找到一种方法将它绘制到画布上。所以,我们继续前进。
draw()
函数接受你想要绘制的上下文作为参数:
function draw(ctx) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
drawRainScene();
drawScene();
var elements = custom.selectAll('custom.drop');
elements.each(function(d, i) {
var node = d3.select(this);
ctx.save();
ctx.beginPath();
ctx.globalCompositeOperation = 'source-atop'
ctx.fillStyle = node.attr('fillStyle');
ctx.arc(node.attr('cx'), node.attr('cy'), node.attr('r'), 0, 2 *
Math.PI);
ctx.fill();
ctx.restore();
});
然后它执行以下操作:
-
它清除画布。
-
绘制背景场景,包括房屋和树木,以及
drawRainScene()
中绘制的云和积水。 -
它会遍历我们的每个虚拟元素,并根据我们在
databind()
中指定的属性来绘制它。
就这样!你可以关闭 draw()
函数了。
看到这一行 ctx.globalCompositeOperation = 'source-atop'
吗?globalCompositeOperation
允许我们将形状融合或混合。它作用于源形状,即我们即将绘制的形状,以及目标,即源形状下方的画布内容。你可以应用多种合成效果,但我们在这里使用 source-atop
。
检查developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation
以获取所有合成选项。
因此,新形状仅在它与现有画布内容重叠的地方绘制。在没有绘制的画布区域中,形状将不可见。这就是为什么我们需要在drawRainScene()
中的所有对象。它们构成了雨滴的背景,雨滴无法逃离。顺便说一句,如果你不想手动绘制所有复杂的形状,你可以使用如 Illustrator 这样的矢量图形软件绘制它们,将它们保存为 SVG 格式,然后使用诸如www.professorcloud.com/svg-to-canvas/
的SVG 到 HTML5 Canvas 转换器应用将 SVG 路径转换为 Canvas 命令。
运行应用
到目前为止,还没有雨滴出现,但你已经设置了背景场景:
初始场景
你有使用databind()
和draw()
函数来动画化生命周期的手段。你只需按顺序调用它们,并将这些调用与按钮连接起来。让我们通过进入案例来运行这个。云层是空的,我们希望雨滴从 0 到 0.2 的不透明度过渡到活动状态,这就是我们在databind()
函数中指定的。我们可以直接运行:
databind(raindata);
draw(context);
这将把数据连接到自定义元素中,并且随着所有元素都连接了新数据,将绘制整个选择区域——只需一次!但是我们需要显示过渡效果,因此在过渡发生时需要重复绘制。你可以通过一个连续循环来实现这一点:
databind(data);
var t = d3.timer(function(elapsed) {
draw(context);
if (elapsed > dur * 2) t.stop();
});
首先,我们将传入的数据绑定到我们的自定义元素上。然后我们重复绘制。d3.timer()
会反复调用其回调函数,直到你告诉它停止。回调函数接受一个我们称之为elapsed
的参数,它是计时器运行的时间(以毫秒为单位)。draw()
函数将会多次运行,绘制背景场景以及每个雨滴。同时,在databind()
中运行一个过渡,稍微改变每个雨滴的位置。draw()
函数会在每次调用时,在循环中为每个雨滴选择这些微小的位置变化,并在databind()
在这个特定时刻设置的非常位置绘制雨滴。你可以将其视为两个同时发生的过程:databind()
中的过渡提供新的雨滴位置,以及draw()
中的重复画布绘制移除之前的画布并绘制这些新的雨滴位置。
过渡完成后,我们想要停止计时器。工作完成。databind()
中的过渡持续 2000 毫秒,正如我们在dur
变量中设置的。我们现在将使用dur
来清理我们的工作。我们可以从调用计时器的.stop()
方法中停止任何计时器。为了安全起见,我们在超过双倍持续时间dur
(4000毫秒)后调用t.stop()
以适应延迟的过渡。
这就是 D3 在 Canvas 中如何进行过渡。你反复调用绘图函数,与绑定函数大致并行。无论你的 D3 元素设置了哪些过渡样式或位置属性(例如x,y,color,width,height),它们都将被重新绘制多次,每次绘制都有微小的增量变化。
将其连接到按钮是形式上的。只需将databind()
和draw()
函数包装到一个函数中(我们将称之为rainAnimation()
),当按下进入或更新按钮时传递raindata
,当按下退出按钮时传递一个空数组。
就这样!
当按下 Enter 按钮时,雨云将出现在屏幕上:
将雨进入云中
更新按钮将雨的位置从云更新到水坑:
更新雨到地面
最后,按下 Exit 按钮将雨变成草:
让雨退出变成草
在浏览器中查看此步骤:larsvers.github.io/learning-d3-mapping-8-4a
。代码示例08_04a.html。
这是一个自然的 D3 生命周期演示!
概述
你已经走得很远了。你已经学会了如何使用 Canvas 绘图,如何以纯 Canvas 方式对 Canvas 进行动画处理,以及如何使用 D3 过渡和 Canvas 的 Enter-Update-Exit 模式。虽然纯 Canvas 方式对于许多应用来说完全合理,但 D3 提供了用于数据可视化的成熟功能,你不必放弃。在构建你的应用程序时,这需要思维上的转变,但它可以特别有利于绘制和动画化大量点。它将以有价值的方式扩展你的工具箱,尤其是在大量数据可能需要元素密集型表示的时候。
在下一章中,我们将回顾 SVG 中的地图可视化,然后在 Canvas 中构建一个。通过这样做,你不仅能够应用本章的学习内容,而且你还将了解更多关于两种方法之间的差异和相似之处,Canvas 如何帮助解决性能瓶颈,以及 D3 如何帮助处理一些繁重的工作。
让我们开始吧!
第九章:使用 Canvas 和 D3 进行映射
是时候离开我们的房子和树了。我知道这很悲伤,但我们将继续探索可能更有趣的事情来构建。在上一章中,你学到了如何使用 Canvas 绘制,如何使用 Canvas 动画,以及如何将 D3 生命周期与 Canvas 结合的模式。由于 D3 通常与 SVG 合作,你也了解了一些 SVG 和 Canvas 之间的关键差异。了解任何一种方法的优缺点对于做出关于使用哪种渲染模式的明智决策至关重要。以下是本章我们将要探讨的内容:
-
我们将首先总结使用 SVG 或 Canvas 的关键原因。
-
然后,我们将继续回顾使用 SVG 构建飞行路径可视化的步骤,然后再使用 Canvas 构建。
-
在此过程中,我们将关注性能测量,以了解我们可以使用这两种方法走多远。
这将进一步从概念和技术上对比和比较这两种方法。它还将使我们能够展示选择 Canvas 而不是 SVG 的主要原因——大量点的动画。
选择 Canvas 或 SVG
当使用这两种渲染方法中的任何一种时,你已经看到了一些好处和一些需要克服的挑战。本节旨在总结最重要的差异。因此,它应该能让你了解在何种情况下使用什么。请注意,我是在比较 SVG 和 Canvas,而不是 HTML 和 SVG 与 Canvas。由于 SVG 在可视化方面的优势,将其作为 D3 的主要构建块来关注似乎是合适的。然而,同样的逻辑也适用于同样保留的 HTML。
选择 SVG 的原因
让我们先看看 SVG 的好处:
-
SVG 是一种基于矢量的图形系统。它允许无分辨率依赖的绘制,你可以进行缩放而不会影响质量。
-
你可以轻松访问 DOM 中的元素来移动、更改或添加交互性。
-
你可以使用 CSS 进行样式设置。
-
D3 与 DOM 密切合作,允许进行简洁的操作,如单次遍历中的元素选择和样式设置,以及使用 SVG 的声明性动画。
-
SVG 默认对屏幕阅读器和 SEO 机器人是可访问的。Canvas 需要回退文本或子 DOM 以提供一定程度的可访问性。
选择 Canvas 的原因
虽然 SVG 可能更容易处理,但在显示和动画更多元素时,Canvas 具有优势:
-
SVG 允许你绘制大约 10,000 个元素并动画化大约 1,000 个元素。使用 Canvas,你可以动画化大约 10,000 个点。为什么?首先,Canvas 是一个更低级的系统,内存中需要保持和管理的抽象层更少。其次,浏览器(如大多数显示器)主要支持每秒 60 帧的帧率,这意味着屏幕每秒更新 60 次。这留下了 1000 / 60 = 16.67 毫秒来完成所有必要的渲染和清理活动。由于人类大脑被欺骗以在每秒 16 帧的速率感知流畅的动画,渲染一帧的最大时间是 1000 / 16 = 62.5 毫秒——但你应该努力缩短这个时间。对于 SVG,这些活动包括 DOM 解析、渲染树生成、布局和屏幕绘制,仅举最重要的例子。Canvas 和图像之间的路径更短。浏览器在将其绘制到画布上之前,将上下文指令转换为一个像素值的数组。
-
如果你需要渲染或动画更多元素,访问替代的 WebGL 上下文就像定义
canvas.getContext('webgl')
一样简单。WebGL 允许你动画化 10k 个元素甚至更多。虽然 WebGL 代码接近 GPU 编程,因此不适合胆小的人,但像Three.js
、Pixi.js
或regl
这样的抽象库使其更容易访问。
查看彼得·贝沙伊(Peter Beshai)关于使用 WebGl 和 regl 动画 100,000 个点的出色教程,peterbeshai.com/beautifully-animate-points-with-webgl-and-regl.html
。
- Canvas 是一个 光栅化 的图形系统。这仅仅意味着图像由一个 光栅(我们也可以说是一个 矩阵)的像素组成。因此,缩放可能会导致模糊,但反过来,将你的 Canvas 作为图像下载是简单的。另一个问题是高 每英寸点数(DPI)或视网膜屏幕,这可能会使 Canvas 模糊。你可以使用以下设置来支持 Canvas 上的视网膜显示:
var devicePixelRatio = window.devicePixelRatio || 1
var canvas = d3.select(‘body').append(‘canvas')
.attr(‘width', width * devicePixelRatio)
.attr(‘height', height * devicePixelRatio)
.style(‘width', width + ‘px')
.style(‘height', height + ‘px');
var context = canvas.getContext(‘2d');
context.scale(devicePixelRatio, devicePixelRatio);
考虑到这一点,似乎尽可能长时间坚持使用 SVG 是明智的选择,当需要绘制或移动许多元素时再拿出 Canvas。你可能想要保持简单,直到不能再简化为止。一个不那么简单的情况可能是大量点的动画。让我们通过首先使用 SVG 然后使用 Canvas 构建一个元素密集型、动画化的应用程序来演示 Canvas 的性能优势。
使用 Canvas 和 D3 可视化飞行路径
数据可以包含大量单独的数据点。地图尤其可以成为大型数据集的游乐场。虽然将数据集的特征可视化为一组单独的元素可能很有吸引力,但在解释性数据可视化中,通常有道理将数据聚合起来,以便更好地传达一个观点。虽然 Canvas 允许你显示和动画化许多点,但你可能希望负责任地使用这种能力。
话虽如此,观察动态数据展开以及传达特定观点往往令人着迷。结合用户参与和简洁的学习内容当然是一个很好的加分项,如果可能的话应该加以利用。考虑到地图数据,有许多动态可视化示例,包含众多动画元素,例如自然元素如风或洋流,文化元素如思想的传播或发明,以及技术元素如汽车、船只或飞机。在本节中,我们将关注后者,并可视化飞行路径。
我们的目标将是双重的。首先,我们希望构建一个包含许多动画元素的地图可视化——不仅仅是为了展示许多元素,但我们展示的细节应该有助于理解视觉效果。其次,我们希望比较 SVG 和 Canvas 的性能。我们已经在之前的章节中从理论上进行了描述,但现在让我们来实践一下。
我们将构建以下内容:
1,000 条飞行路径可视化。每个红色圆点都是一个动画飞机(保证!)
我们将绘制三个主要元素类别:世界、机场(白色圆点,有意识地保持在背景中,因为它们只有辅助作用),以及飞机(红色圆点)。代表真实飞机的红色圆点将沿着它们自己的飞行路径动画化,从起点飞往目的地。以下是展示飞机飞行路径的图片:
显示 100 条航线的路径及其相应的飞机
此视觉效果的源数据包括超过 65,000 条全球航线,飞往和来自超过 7,000 个机场。我们无法动画化所有这些航线,即使是使用 Canvas 也无法实现。我们的可视化目标将是尽可能多地展示,以传达对活跃与不活跃飞行区域以及常用与较少使用的航线的直观理解。
在视觉效果的底部,我们将展示一排按钮:
启动动画的按钮
这些按钮将允许用户设置一次显示的航班数量。重要的是,这不会是实时或重放的。我们不会引入任何航班时刻表,显示航班在当天/日期出发或到达的时间,我们将同时显示所有航班!首先,这支持之前描述的可视化目标,其次,它将有助于测试性能,因为尽可能多的元素将在同一时间被动画化。
为了测试浏览器性能,我们将在应用程序的左上角添加一个来自stats.js
的小信息框。一旦将其放入你的代码中,这个巧妙的工具就会在你的页面上显示页面性能指标,其中我们将主要对每秒帧数(FPS)感兴趣。你很快就会看到它的实际效果,但这是放大后的样子,表示每秒 60 帧:
数据
根据我们想要表示的三个元素类别,我们需要三个数据源来构建可视化。这些数据源包括地图数据、机场位置数据以及每架航班的起点和终点数据。我们将称之为路线数据。这两个数据集都来自openflights.org,它提供了一个工具,你可以用它来绘制航班图,以及包括路线和机场位置数据在内的全球航班数据库。这正是我们所追求的。
在进行简单的清洁和少量修改后,包含 100 个航班的路线数据和机场位置数据的前 10 条记录如下:
路线数据和机场数据
所有变量名都是自解释的。请注意,机场数据中的第一个变量 *iata,代表国际航空运输协会(IATA)的官方三位字母机场代码。此外,请注意,我们必须根据数据集删除一些航班,因为并非每个机场位置都可用,这实际上导致航班数量(少于 2-3%)低于按钮所暗示的数量。
在 SVG 中构建飞行路径图
在本章中,我们将重点关注使用 Canvas 进行映射,以及 Canvas 动画的基准测试。为了明智地利用我们的时间和精力,我已经预先构建了一个 SVG 地图,我们可以将其用作基准,这样我们就可以将本章的其余部分专注于如何构建 Canvas 飞行路径应用程序。毕竟,章节的标题是使用Canvas和 D3 进行映射...
尽管如此,让我们快速看一下构建此应用程序所需的步骤。大致有八个逻辑步骤需要遵循:
-
你使用容器 SVG、投影和地图路径生成器设置地图
-
你加载地图数据并绘制地图
-
你监听按钮事件,并根据按下的按钮加载适当的数据集
-
你绘制机场
-
你计算每架飞机的起点和终点位置,并计算从起点到终点的路径
-
你沿着每架飞机的路径采样点并将它们存储在数组中(我们将它们称为航点)
-
使用 D3,你让每架飞机沿着其路径过渡
-
当每架飞机到达目的地后,你让过渡再次开始
现在我们已经创造出了 SVG 飞行路径可视化,让我们看看我们能够启动多少架飞机而不会出现任何问题。
测量性能
所以,让我们开始吧——让一些飞机飞起来。我们应该从简单做起,尝试 100 条路线:
60 FPS 下 100 条飞行路径的 SVG 可视化
看看左上角的帧率吗?它有点小,但我们仍然非常满意!每秒显示 60 帧是完美的。1,000 架航班将给我们带来高达 40 帧每秒。这是一个下降,但动画仍然流畅。然而,即使在 1,000 架航班的情况下,我们也看不到任何主要的航班活动集群。所以,让我们尝试同时显示 5,000 架航班:
6 FPS 下 5,000 条飞行路径的 SVG 可视化
我们的性能下降到 6 FPS。虽然这个静态图像让我们更接近我们的可视化目标,即识别高航班交通区域,但观看这种抖动的动画并不有趣。Canvas 来拯救。
在浏览器中查看此步骤:larsvers.github.io/learning-d3-mapping-9-1
。代码示例09_01.html。我建议使用最新版本的 Chrome 来查看和操作本章的示例。
在每个步骤结束时,您将在靠近相关图像的信息框中找到两个链接。第一个链接将带您到浏览器中可以查看的此步骤的工作实现。第二个代码示例链接将带您到完整的代码。如果您正在阅读印刷版,您可以在github.com/larsvers/Learning-D3.js-4-Mapping
的相关章节中找到所有代码示例。
在 Canvas 中构建飞行路径地图
在我们将应用拆解之前,让我们先概述一下我们的 Canvas 应用。
最好快速处理 HTML,因为它不可能更简单。我们手头有一个用于画布的 div
以及我们的按钮:
<div id="canvas-map"></div>
<div id="controls">
<div class="flight-select" id="button-header">Pick number of flights:</div>
<button class="flight-select" data-flights="100">100</button>
<button class="flight-select" data-flights="1000">1,000</button>
<button class="flight-select" data-flights="5000">5,000</button>
<button class="flight-select" data-flights="10000">10,000</button>
<button class="flight-select" data-flights="15000">15,000</button>
<button class="flight-select" data-flights="20000">20,000</button>
<button class="flight-select" data-flights="25000">25,000</button>
<button class="flight-select" data-flights="30000">30,000</button>
</div>
注意,每个按钮都获得相同的类选择器以及一个 data-flights
属性来传递每个按钮代表的航班数量。您将稍后使用此属性来加载正确的数据集!
现在我们来看看在 Canvas 中构建此应用的步骤,并查看我们对之前描述的 SVG 应用流程所做的更改。我已经突出显示了 Canvas 流程中更改的部分,并删除了 SVG 部分(括号内):
-
您设置画布和上下文(而不是容器 SVG),以及地图的投影和路径生成器
-
您加载地图数据并绘制地图
-
您监听按钮事件并根据按下的按钮加载适当的数据集
-
您绘制机场和世界地图,因为它们位于同一个 Canvas 上,重绘成本很低
-
您计算每架飞机的起点和终点位置,并计算从起点到终点的路径
-
您在每个飞机的路径上采样航路点并将它们存储在数组中
-
您启动游戏循环(而不是使用 D3 过渡):
-
清除 Canvas
-
更新位置
-
绘制飞机
-
-
在 SVG 示例中,我们一旦每架飞机到达目的地,就重新启动一个过渡。在我们的 Canvas 应用程序中,这是游戏循环中的更新步骤的一部分。
设置地图
首先,我们设置了一些全局变量:
var width = 1000,
height = 600,
countries,
airportMap,
requestID;
width
和height
不言而喻。国家将持有 GeoJSON 数据来绘制地球,这需要从各种函数作用域中访问。因此,在这个小型应用程序中将其定义为全局变量更容易。airportMap
将允许我们通过三位字母 IATA 代码将机场与路线数据连接起来。requestID
将被我们的循环函数requestAnimationFrom()
填充,并用于取消当前循环。我们很快就会了解到这一点。
然后,我们设置两个上下文:一个用于世界的上下文和一个用于飞机的上下文。这种在开始时的小额外工作使我们的生活后来容易得多。如果我们把世界和飞机画在同一个上下文中,每次飞机飞行的距离很短时,我们就必须更新世界和飞机。将世界保持在单独的画布上意味着我们只需要绘制一次世界,并且可以保持该图像/上下文不变:
var canvasWorld = d3.select('#canvas-map').append('canvas')
.attr('id', 'canvas-world')
.attr('width', width)
.attr('height', height);
var contextWorld = canvasWorld.node().getContext('2d');
var canvasPlane = d3.select('#canvas-map').append('canvas')
.attr('id', 'canvas-plane')
.attr('width', width)
.attr('height', height);
var contextPlane = canvasPlane.node().getContext('2d');
我们使用绝对 CSS 定位来堆叠画布,使其完美地堆叠在一起:
#canvas-world, #canvas-plane {
position: absolute;
top: 0;
left: 0;
}
接下来,我们设置投影
:
var projection = d3.geoRobinson()
.scale(180)
.translate([width / 2, height / 2]);
请注意,您可以使用 D3 方便的方法.fitExtent()
或.fitSize()
而不是使用.scale()
和.translate()
来居中和调整您的投影。您将 viz 维度和您想要投影的 GeoJSON 对象传递给它们,它们会为您计算最佳比例和转换。
还要注意,我们不是使用无处不在的墨卡托投影,而是使用罗宾逊投影来绘制我们的世界地图。它在国家大小比例方面以稍微更真实的方式绘制世界。罗宾逊和其他许多非标准投影可以在额外的d3-geo-projection 模块中找到。
现在我们需要一个路径生成器。实际上,你需要构建两个路径生成器:
var pathSVG = d3.geoPath()
.projection(projection);
var pathCanvas = d3.geoPath()
.projection(projection)
.pointRadius(1)
.context(contextWorld);
pathSVG
将用于在内存中生成飞行路径。我们希望使用 SVG 来完成,因为它提供了方便的方法来计算其长度和从中提取样本点。pathCanvas
将用于将我们的geo
数据绘制到屏幕上。注意,我们添加了d3.geoPath()
的.context()
方法,并将其传递给我们的contextWorld
。如果我们向这个.context()
方法传递一个 Canvas 上下文,路径生成器将返回一个针对该上下文的 Canvas 路径。如果没有指定,它将返回一个 SVG 路径字符串。你可以将其视为一个切换按钮,告诉 D3 使用哪个渲染器。
绘制地图并监听用户输入
与 SVG 过程一样,我们首先加载数据:
d3.json('data/countries.topo.json', function(error, world) {
if (error) throw error;
d3.select('div#controls').style('top', height + 'px');
countries = topojson.feature(world, world.objects.countries); // GeoJSON;
drawMap(countries);
然后我们做一些清理工作,并将按钮移动到 div#controls
下方画布下面。你在绘制地图之前,将 TopoJSON 重新编码为 GeoJSON 功能,并将数据保存为全局变量:
function drawMap(world) {
countries.features.forEach(function(el, i) {
contextWorld.beginPath();
pathCanvas(el);
contextWorld.fillStyle = '#ccc';
contextWorld.fill();
contextWorld.beginPath();
pathCanvas(el);
contextWorld.strokeStyle = '#fff';
contextWorld.lineWidth = 1;
contextWorld.stroke();
});
}
多亏了 D3 的多功能路径生成器,绘制世界地图只需要这些。很简单!
在我们的异步 d3.json()
数据加载函数中,接下来你将处理按钮事件。记住,目前还没有发生任何事,但一旦用户点击按钮,动画就应该开始。
你将鼠标按下监听器附加到所有按钮上:
d3.selectAll('button.flight-select').on('mousedown', handleFlights);
继续编写处理程序:
function handleFlights() {
d3.selectAll('button').style('background-color', '#f7f7f7');
d3.select(this).style('background-color', '#ddd');
if (requestID) cancelAnimationFrame(requestID);
var flights = this.dataset.flights;
d3.queue()
.defer(d3.csv, 'data/routes_' + flights + '.csv')
.defer(d3.csv, 'data/airports_' + flights + '.csv')
.await(ready);
}
按钮颜色在前两行处理。下一行将停止当前循环。我们甚至还没有循环,所以一旦我们有循环,我们就会回到这里。
最后,我们检索按钮代表的航班数量,并从服务器加载相应的航线和机场位置数据。这就是 d3.json()
回调的全部内容,因为一旦数据加载,ready()
函数就会接管。
准备和绘制 Canvas
在 ready()
函数中,我们想要在 Canvas 上实现三件事:
function ready(error, routes, airports) {
if (error) throw error;
// 1) Draw the background scene
// 2) Calculate plane positions
// 3) Animate and render the planes
}
绘制背景场景
在我们绘制机场之前,我们操纵机场位置数据。我们创建一个数组,每个机场包含一个 GeoJSON 点 geometry
对象:
var airportLocation = [];
airports.forEach(function(el) {
var obj = {};
obj.type = 'Feature';
obj.id = el.iata;
obj.geometry = {
type: 'Point',
coordinates: [+el.long, +el.lat]
};
obj.properties = {};
airportLocation.push(obj);
});
airportMap = d3.map(airportLocation, function(d) { return d.id; });
然后我们使用 d3.map()
函数生成一个地图,并将其填充到全局变量 airportMap
中。d3.map()
是一个实用函数,它接受一个对象数组,生成我们可以通过其 map.get()
方法访问的键值对。我们不会立即使用这个地图,但稍后我们会用到它。
每次我们调用 ready()
函数,也就是每次用户按下新按钮时,我们都会重新绘制机场和世界。两者都绘制在同一个画布上。如果我们想在画布上更改一项内容,我们需要更改画布上的所有内容。有方法只更新具有 clip-paths 的区域,但在多个元素的复杂动画中,这可能会很快变得混乱。因此,我们擦除并重建:
contextWorld.clearRect(0, 0, width, height);
drawMap(countries);
drawAirports(airportLocation);
注意,我们正在绘制第一个画布 - 通过 contextWorld
访问。我们之前看到过 drawMap()
函数,drawAirports()
函数甚至更简单,不言自明:
function drawAirports(airports) {
airports.forEach(function(el,i) {
contextWorld.beginPath();
pathCanvas(el);
contextWorld.fillStyle = '#fff';
contextWorld.fill();
});
}
就这样。这个背景场景将在每次按钮点击时更新显示的机场。
定义飞机
接下来,我们为我们的动画建立基础。本质上,我们想要每个飞机航线上的一系列点。我们将它们称为航路点,以下是飞行路径法兰克福到亚特兰大的航路点作为数组和路径上的点:
航路点在数组中(显示前 10 个 733 个)和在地图上(一个示意图,不是精确的)
航路点是动画的核心成分,是我们的动画燃料。当我们动画第一帧时,我们将:
-
清除飞机自己的画布
contextPlane
。 -
提取每个飞机的第一个航路点。
-
在这个位置绘制那架飞机。
当我们绘制第二帧时,我们做同样的事情,但在 步骤 2 中提取第二个 wayPoint
。对于第三帧,我们将提取第三个 wayPoint
,依此类推。
我们不希望浏览器在每一帧之间因为复杂的计算而停滞,所以我们将在动画之前计算所有飞机的位置。注意,这并不总是可能的,位置可能取决于用户输入或你的力导向图中的任意电荷等因素。然而,无论你能预先计算什么,都应该这样做。
计算飞机的位置
我们如何得到 wayPoints
数组?从概念上讲,我们已经说过了。我们现在用代码表达它。首先,你需要创建一个数组来存储所有飞机,这取决于相应按钮点击加载的路线数据:
var routeFromTo = [];
routes.forEach(function(el) {
var arr = [el.source_airport, el.destination_airport];
routeFromTo.push(arr);
});
这是一个简单的元素数组,代表三位字母的起点和目的地 IATA 机场代码。
接下来,你遍历这个起点和终点数组来计算 wayPoints
。你将创建一个名为 planes
的对象来存储数据,以及两个辅助函数来计算数据。但在那之前,让我们看看生成飞机的简单算法:
routeFromTo.forEach(function(el, i) {
var plane = planes.getPlane(el);
plane.route = planes.getPath(el);
plane.wayPoints = planes.getWayPoints(plane);
planes.items.push(plane);
});
从概念上讲,你为每条路线生成一架飞机。然后你获取这架飞机的路线路径并将其存储在飞机中。接下来,你采样路径的多个 x,y 坐标 – 我们的 wayPoints
– 并也将它们存储在 plane
中。最后,你将包含所有所需信息的 plane
添加到 planes.items
数组中。这就是概述中的所有计算魔法。一旦完成,你就可以动画化这些点。
现在,让我们简要地看看 planes
对象。注意复数形式!这与我们为每条路线构建的 plane
对象不同。它是所有 plane
对象的家。planes.items
将保存所有 plane
对象,planes.getPlane()
将生成它们,planes.getPath()
将创建路线的路径,而 planes.getWayPoints()
将从路径中采样我们的 wayPoints
:
var planes = {
items: [],
getPlane: function(planeRoute) { },
getPath: function(planeRoute) { },
getWayPoints: function(plane) { }
}
让我们看看每个 planes
函数的作用。这里有三个简单的步骤:首先,我们构建飞机,然后绘制每架飞机的路径,最后从该路径中采样点,我们可以迭代这些点来使飞机移动:
- 制造飞机:
getPlane()
函数接受planeRoute
– 起点和目的地的三位字母机场代码 – 并用它来初始化飞机的位置:
getPlane: function(planeRoute) {
var origin = planeRoute[0], destination = planeRoute[1];
var obj = {};
obj.od = [origin, destination];
obj.startX = projection(airportMap.get(origin).geometry.coordinates)[0];
obj.startY = projection(airportMap.get(origin).geometry.coordinates)[1];
obj.x = projection(airportMap.get(origin).geometry.coordinates)[0];
obj.y = projection(airportMap.get(origin).geometry.coordinates)[1];
obj.route = null;
obj.wayPoints = [];
obj.currentIndex = 0;
return obj;
}
它返回一个对象,包含从你之前创建的 airportMap
查找中检索到的 startX
和 startY
位置。它还有 x
和 y
坐标,代表飞机的当前位置。对于第一帧,这和 startX
和 startY
相同。它还包含一个尚未填写的对象,用于 route
路径和我们在下一步计算的 wayPoints
。最后,它有一个 currentIndex
,用于跟踪我们在改变飞机位置时飞机所在的 wayPoint
(这很快就会变得清楚)。
- 绘制每架飞机的路径:飞机初始化。现在,让我们获取路径。记得我们在设置期间创建了两个路径生成器吗?一个是用于绘制世界、机场和平面圆的 Canvas 路径。另一个是
pathSVG
,用于创建作为 SVG 路径的路线。你为什么要这样做呢?因为 SVG 路径有很好的.getTotalLength()
和.getPointAtLength()
方法,这使得从该路径中采样点变得容易。以下是使用 D3 创建路径的方法:
getPath: function(planeRoute) {
var origin = planeRoute[0], destination = planeRoute[1];
var pathElement = document.createElementNS(d3.namespaces.svg,
'path');
var route = d3.select(pathElement)
.datum({
type: 'LineString',
coordinates: [
airportMap.get(origin).geometry.coordinates,
airportMap.get(destination).geometry.coordinates
]
})
.attr('d', pathSVG);
return route.node();
}
你不会在 DOM 中创建路径,而是在内存中创建并保存到pathElement
变量中。由于它是一个 SVG 而不是 HTML 元素,你需要指定 SVG 命名空间,这可以通过 D3 的.namespaces.svg
实用函数来完成。然后你在返回原始元素而不是 D3 选择route.node()
之前创建路径。
- 检索航点:所有设置都已就绪以计算航点。
getWayPoints()
接受一个飞机,此时飞机的路径已存储在plane.route
属性中。我们使用我们刚刚赞扬的路径采样函数来处理其路径,并返回一个包含该特定飞机路线路径所有航点的数组:
getWayPoints: function(plane) {
var arr = [];
var points = Math.floor(plane.route.getTotalLength() * 2.5);
d3.range(points).forEach(function(el, i) {
var DOMPoints = plane.route.getPointAtLength(i/2.5);
arr.push({ x: DOMPoints.x, y: DOMPoints.y });
});
return arr;
}
首先,你创建一个名为arr
的空数组,它将保存所有航点。然后,你生成一个保存在points
变量中的整数。这个整数将代表我们从路径中想要采样的点的数量。你获取路径的总长度,这由路径将占据的像素数表示。这个值乘以2.5
。这个因子非常重要,它控制了将采样多少点以及动画的快慢。数字越高,采样的点越多,动画看起来越慢。如果你选择一个低数字甚至是一个分数,如0.1,采样的点会很少,动画看起来会更快。
你使用d3.range(points).forEach()
来检索在每个路径点由.getPointAtLength()
返回的所谓DOMPoints
坐标。然后你将它们中的每一个推入数组,然后就是你的航点。
恭喜你。你刚刚构建了一架飞机。实际上,你构建了一架飞机及其路线以及所有需要使其跳跃的点,这样观众就会认为它在飞行。这就是它内部的样子:
从法兰克福飞往亚特兰大的飞机
飞机动画
剩下的很简单。你只需要将游戏循环应用到画布上。我们已经遇到过几次这种情况;你创建一个名为animate()
的函数,并让它在一个连续的循环中运行:
function animate() {
planes.clearPlanes(contextPlane);
planes.items.forEach(function(el) {
planes.updatePlane(el);
planes.drawPlane(contextPlane, el.x, el.y);
});
requestID = requestAnimationFrame(animate);
}
requestAnimationFrame(animate);
注意,我们还把使用的函数添加到了飞机对象中,以保持所有与飞机相关的函数代码在一起。
首先,我们清除画布。planes.clearPlanes()
实际上只是清除我们传递给它的上下文。
然后,我们遍历包含所有飞机的planes.items
数组,并使用planes.updatePlane()
更新每个飞机。我们传递相应的飞机,如果飞机已到达目的地,则将其x
和y
坐标移动到起点,或者将其移动到下一个航点的坐标:
updatePlane: function(plane) {
plane.currentIndex++;
if (plane.currentIndex >= plane.wayPoints.length) {
plane.currentIndex = 0;
plane.x = plane.startX;
plane.y = plane.startY;
} else {
plane.x = plane.wayPoints[plane.currentIndex].x;
plane.y = plane.wayPoints[plane.currentIndex].y;
}
}
在这里,currentIndex
的使用应该变得更加清晰。它跟踪每个飞机在其路径上的位置,并在每次更新时将飞机向前移动一个航点。
最后,我们绘制了平面(这时我们意识到我们并没有真正建造一个飞机,而是一个番茄色的圆圈):
drawPlane: function(ctx, x, y) {
ctx.beginPath();
ctx.fillStyle = 'tomato';
ctx.arc(x, y, 1, 0, 2*Math.PI);
ctx.fill();
}
最后,你使用requestAnimationFrame()
来启动它。你可以使用setInterval()
,但你应该使用requestAnimationFrame()
。它将允许浏览器在下次重绘之前选择最佳时间来触发其回调。这比强制的setInterval()
更经济。它还有额外的优点,即当应用程序运行的浏览器标签页不在焦点时,可以中断循环。注意,我们还保存了每个循环的requestID
。你可能记得,当用户按下按钮启动新循环时,我们使用这个唯一的 ID 通过cancelAnimationFrame(requestID)
取消当前循环。
完成。做得好。
性能测量
到目前为止,一切顺利。但是它工作吗?如果它工作,它比 SVG 示例工作得更好吗?让我们回忆一下,SVG 飞行路径可视化为我们提供了 100 个动画圆的 60 FPS 和大约 5,000 个动画圆的 6 FPS。让我们从 100 个圆开始,关注左上角的stats.js
度量:
60 FPS 的 100 条飞行路径的 Canvas 可视化
在浏览器中查看此步骤:larsvers.github.io/learning-d3-mapping-9-2a
。代码示例09_02a.html。
我们期望 60 FPS。让我们增加到 1,000 架航班:
60 FPS 的 1,000 条飞行路径的 Canvas 可视化
再次,60 FPS!5,000 架飞机?
45 FPS 的 5,000 条飞行路径的 Canvas 可视化
它正在下降,但仍然保持在 45 FPS,保持动画流畅。让我们看看 10,000 架航班:
23 FPS 的 10,000 条飞行路径的 Canvas 可视化
我们仍然看到与观看 23 FPS 电影时相似的帧率。然而,让我们尝试挤出更多一些。
性能优化
记住,我们为这个可视化使用了两个 Canvas,一个 Canvas 用于绘制带有地图和机场的静态背景场景,另一个 Canvas 用于动态飞行动画。我们这样做是因为它保持了绘制关注点的分离。
使用额外画布的另一个原因是提高了性能。我们可以使用一个画布作为 内存缓冲区 来预渲染元素,然后将其内容复制到主可见画布上。这节省了渲染成本,因为绘制在可见画布上比绘制在不可见画布上然后复制图像到主画布要低效。性能进一步得到提升,因为我们将要使用的上下文的 drawImage()
方法默认是硬件加速的(这意味着它使用 GPU 的并行处理能力)。
对于我们的小程序,动画元素是飞机圆圈。我们不必在每次更新时使用 drawPlane()
函数来绘制它们,而是可以先在小型缓冲画布上创建一个圆圈的单一图像,然后使用 drawImage()
将其传输到 canvasPlane
。
我们在全局范围内创建了一个单独的飞机图像:
function createPlaneImage() {
var planeImg = document.createElement('canvas');
planeImg.width = planeImg.height = 2;
var contextPlaneImg = planeImg.getContext('2d');
contextPlaneImg.beginPath();
contextPlaneImg.fillStyle = 'tomato';
contextPlaneImg.arc(planeImg.width/2, planeImg.height/2, 1, 0,
2*Math.PI);
contextPlaneImg.fill();
return planeImg;
}
我们在空中创建了一个名为 planeImg
的缓冲画布,将其 width
和 height
设置为 2
(是平面期望半径 1
的两倍),并获取其上下文。在返回之前,我们将在其上绘制一个 tomato
颜色的圆圈。
我们在初始化 planes
对象时调用此函数一次,并将其作为图像存储在 planes
对象中:
var planes = {
items: [],
icon: createPlaneImage(),
getPlane: function(planeRoute) {
// ...
最后,我们只需移除我们用来在每次更新时绘制圆圈的 drawPlane()
函数。相反,我们向 planes
对象添加一个名为 drawPlaneImage()
的新函数,该函数使用 drawImage()
将我们的飞机图标(圆圈)添加到我们确定的上下文中:
drawPlaneImage: function(ctx, x, y) {
ctx.drawImage(this.icon, x, y);
}
最后,我们在 animate()
函数中不调用 drawImage()
,而是调用 drawPlaneImage()
:
function animate() {
planes.clearPlanes(contextPlane);
planes.items.forEach(function(el) {
planes.updatePlane(el);
planes.drawPlaneImage(contextPlane, el.x, el.y);
});
requestID = requestAnimationFrame(animate);
}
继续测量性能
现在让我们检查动画 10,000 个点的帧率:
在 36 FPS 而不是 23 FPS 下,10,000 条飞行路径的画布可视化
查看完整应用程序:larsvers.github.io/learning-d3-mapping-9-2b
。代码示例:09_02b.html。
这太棒了,与不使用 drawImage()
的能力相比,性能提升了约 +57%。这里没有显示,但 5,000 个点以 60 FPS 而不是 45 FPS 动画。耶。
接下来,15,000 架飞机以 24 FPS 飞行,20,000 架飞机以高达 18 FPS 飞行。这仍然略高于通常认为的最低可能帧率 16 FPS,以欺骗大脑相信流畅的动画。即使 25,000 架飞机仍然以大约每秒 14 帧的速度移动,表现出轻微的卡顿,而 30,000 架飞机在 12 FPS 下仍然只有轻微的卡顿。
在 12 FPS 下,30,000 条飞行路径的画布可视化
虽然性能会因不同的浏览器、CPU 和 GPU 而异,但从 SVG 跳转到 Canvas 的提升是显著的!
使用 Canvas,我们实现了我们的叙事任务,即可视化高飞行活动区域。整个欧洲似乎都在空中,以及美国的东海岸和西海岸以及中国的东部。其他大陆在其海岸线上显示出增加的航空活动线。你可能会对沿着泰国和印度尼西亚移动的飞机带感到惊讶,尽管这是一个人口密集的地区。
摘要
在本章中,你学习了如何使用 SVG 和 Canvas 构建飞行路径可视化,将之前学到的许多知识整合在一起。你看到了使用 Canvas 编程动画需要不同的思维模式,这最好通过游戏循环来概括:处理数据,清除绘图,重新绘制动画。你使用了 D3 来设置可视化,但也看到了由于不同的编码概念,Canvas 可能需要你稍微远离 D3 的核心功能,如过渡效果。然而,所有这些努力都得到了回报,因为我们见证了 Canvas 在动画方面的强大功能。我们不仅能够流畅地动画化 1,000 个点,还通过优化的 Canvas 技巧安全地动画化了 15,000 个点,并且优雅地动画化了 20,000 个点。
在看到了 Canvas 的好处之后,现在让我们找到一个解决其缺点之一的方案:交互性!
第十章:为您的 Canvas 地图添加交互性
在上一章中,您看到了 Canvas 的一个闪亮特性——在屏幕上以平滑动画的方式动画化成千上万的点。在本章中,您将处理 Canvas 的一个注意事项:交互。虽然与 HTML 或 SVG 元素的交互是直接的,但使用 Canvas 进行交互需要更多的思考和一些技巧。在本章中,您将跟随这些思路,学习完成以下任务所需的技巧:
-
使地球移动,并为其添加缩放和旋转交互
-
学习如何通过拾取与 Canvas 元素交互
-
从 Canvas 元素中检索数据以在工具提示中显示
在本章之后,您将涵盖 Canvas、绘图、动画以及最终与 Canvas 交互的所有重要方面。
为什么 Canvas 交互是不同的
在上一章中,您通过移除 DOM 实现了成千上万点的平滑动画。DOM 是浏览器内存中每个元素的表示。绕过它,您在动画过程中处理的开销要小得多。然而,DOM 在 Web 开发的其他目标中非常有用。在列表中高居榜首——特别是对于数据可视化——是屏幕上元素的交互。
您可以为元素添加一个onmousemove
或onclick
监听器。您不能对 Canvas 上的元素这样做。它们是过去代码的像素表现,在浏览器中不表示为模型。
然而,不要绝望,有一些间接但简单的技术可以与您的 Canvas 交互。我们将在本章中查看最重要的技术,构建一个交互式地球:
按国家划分的世界森林覆盖率地图
放弃飞行飞机,您将构建一个显示每个国家森林覆盖率百分比的地球。一个国家越绿(您猜对了),森林覆盖率就越高。正如您在前面的图中可以看到,当您悬停在每个国家上时,您将有一个显示特定国家信息的工具提示。用户还可以随意旋转地球并放大到特定区域。
您可以在larsvers.github.io/learning-d3-mapping-10-4
查看最终的应用程序,以及一个代码示例在10_04.html。
在每个步骤结束时,您将在靠近相关图像的信息框中找到两个链接。第一个链接将带您到一个可以浏览器的实际实现步骤。第二个代码示例链接将带您到完整的代码。如果您正在阅读印刷版,您可以在github.com/larsvers/Learning-D3.js-4-Mapping
的相关章节中找到所有代码示例。
在 Canvas 上绘制世界
让我们从绘制地球仪开始。这很简单,很大程度上是基于前面的章节。
设置
首先,您将创建一些初始化所需的变量。随着应用程序的增长,我们将回到这个列表,但到目前为止,它很简单:
var width = 960,
height = 600,
projectionScale = height / 2.1,
translation = [width / 2, height / 2];
您正在设置 Canvas 的width
和height
以及地球仪的缩放和转换。每个投影都有自己的理想起始缩放。您可以调整这个数字以找到合适的缩放。您将直接使用width
和height
来设置 Canvas 及其上下文:
var canvas = d3.select('#canvas-container').append('canvas')
.attr('id', 'canvas-globe')
.attr('width', width)
.attr('height', height);
var context = canvas.node().getContext('2d');
这里没有魔法。请注意,在我们的 HTML 中有一个带有#canvas-container
ID 的div
,在其中添加主 Canvas。
让我们再生成一个bufferCanvas
。您在前面的章节中学习了 buffer Canvas 的好处。简而言之,在内存中渲染图像并将其复制到主 Canvas 上比直接在主 Canvas 上渲染图像更高效:
var bufferCanvas = document.createElement('canvas');
var bufferContext = bufferCanvas.getContext('2d');
bufferContext.canvas.width = width;
bufferContext.canvas.height = height;
构建地球仪的一个相当核心的部分是正确的投影。构建地球仪将我们的选择大大减少到d3.geoOrthographic()
投影,这是一个 2D 地球投影,它是标准 d3-geo 模块的一部分。您已经在第五章,“点击-点击-爆炸!将交互性应用于您的地图”中使用过它。让我们首先设置它:
var projection = d3.geoOrthographic()
.scale(projectionScale)
.translate(translation)
.clipAngle(90);
我们应用了上面指定的缩放和转换数组,以及将.clipAngle()
设置为 90 度,以便始终裁剪投影的背面,即我们的地球仪。
由于我们使用bufferCanvas
进行所有绘图,因此我们将投影绑定到一个仅绘制到 buffer Canvas 的路径生成器,如下所示:
var bufferPath = d3.geoPath()
.projection(projection)
.context(bufferContext);
您将创建两个更多的地理辅助工具:一个基本的球体和一个经纬网:
var sphere = { type: 'Sphere' };
var grid = d3.geoGraticule()();
这两个都是地理可视化原语。球体是一个您用来在地表下构建地球的球体。然后您可以填充它或给它一个轮廓,以使地球在各国之外呈现圆形。经纬网是主要子午线(经度线)和纬度线(纬度线)的网格,彼此之间相隔 10 度(是的,您需要四个括号来生成实际的经纬网对象)。我们很快就会看到它们的作用。
绘制世界
场景已经设定。在本节中,您将加载一些国家形状数据来绘制世界。您将设置四个小函数来实现绘制:
-
一个
数据加载
函数 -
一个
ready()
函数,它准备数据并将其传递给渲染函数 -
一个启动世界绘制并将最终图像从缓冲区复制到主 Canvas 上的
renderScene()
函数 -
一个将世界渲染到
bufferCanvas
上的drawScene()
函数。
这可能听起来只是为了绘制一个静态地球而有些过度,但请让我向你保证,它确实是。然而,我们正在追求更高的目标,这将大大有助于我们已经建立的结构。
数据加载函数只是请求数据并将其传递给ready()
函数:
d3.json('../../data/world/world-110.json', function(error, data) {
if(error) throw error;
ready(data);
});
到目前为止,ready()
函数并没有增加多少复杂性:
function ready(world) {
var countries = topojson.feature(world,
world.objects.ne_110m_admin_0_countries);
renderScene(countries);
}
它将 TopoJSON 转换为 GeoJSON countries
数组,并调用renderScene()
。renderScene()
做的是我们在前面的代码中已经描述过的。它在bufferContext
上绘制地球,完成后立即将其复制到刚刚清除的主 Canvas 上:
function renderScene(world){
drawScene(world);
context.clearRect(0, 0, width, height);
context.drawImage(bufferCanvas, 0, 0, bufferCanvas.width,
bufferCanvas.height);
}
虽然drawScene()
是我们最长的函数,但它并不复杂:
function drawScene(countries) {
bufferContext.clearRect(0, 0, bufferCanvas.width, bufferCanvas.height);
// Sphere fill
bufferContext.beginPath();
bufferPath(sphere);
bufferContext.fillStyle = '#D9EAEF';
bufferContext.fill();
// Grid
bufferContext.beginPath();
bufferPath(grid);
bufferContext.lineWidth = 0.5;
bufferContext.strokeStyle = '#BDDAE3';
bufferContext.stroke();
// Country fill
bufferContext.beginPath();
bufferPath(countries);
bufferContext.fillStyle = '#FFFAFA';
bufferContext.fill();
// Country stroke
bufferContext.beginPath();
bufferPath(countries);
bufferContext.lineWidth = 0.5;
bufferContext.strokeStyle = '#D2D3CE';
bufferContext.stroke();
}
它清除buffer
上下文,然后以浅蓝色绘制一个基础球体,以略带饱和度的蓝色绘制经纬网格。然后以浅灰色填充国家,并以较深的灰色勾勒每个国家。就是这样。这就是你自己的 Canvas 地球:
一个静态的 Canvas 地球
在浏览器中查看此步骤的larsvers.github.io/learning-d3-mapping-10-1
和代码示例10_01.html。
太好了!你学会了绘制 Canvas 地球,这很好,即使有点单维。那么,让我们添加与它的第一次交互,并让用户(以及我们自己)放大和旋转地球。
让世界移动
我发现放大和旋转地球投影是一项真正愉快的消遣活动。除了很有趣之外,当处理地球投影时,它也非常有用,因为用户需要能够从不同的角度查看世界。
在本节中,我们将向地球添加第一点 Canvas 交互功能。我们将使用户能够放大和旋转地球。除了设置两个额外的全局变量外,我们将在ready()
函数中独家进行工作——我们的中心函数,负责准备数据。从现在开始,它还将处理交互性,就在这里:
function ready(world) {
var countries = topojson.feature(world,
world.objects.ne_110m_admin_0_countries);
requestAnimationFrame(function() {
renderScene(countries);
});
/* Interactivity goes here */
}
此外,请注意,我们将renderScene()
函数包装在requestAnimationFrame()
函数中,以便浏览器始终决定最佳时间点进行新的渲染。
注意,这里是一个处理 D3 中缩放和平移(不是旋转)的突出和常用方法,使用context.scale()
和context.translate()
。然而,为了实现缩放和旋转,我们不会使用这些内置方法,而是会改变投影。我们稍后会回到为什么这样做的原因,因为随着过程的进行,它将变得清晰。
设置行为
缩放不过是改变我们的投影比例。旋转不过是改变我们投影的旋转值。当你想让用户控制缩放和旋转时,你需要监听他们的鼠标移动。因此,你需要设置一个缩放监听器来跟踪用户的鼠标滚轮和拖动操作,并将其附加到画布上。我们已经在第五章中实现了缩放和旋转,点击-点击-爆炸!将交互性应用于您的地图。在我们的ready()
函数中,如上所述,我们将使用 D3 的缩放行为来提供我们需要的所有用户交互变化:
var zoom = d3.zoom()
.scaleExtent([0.5, 4])
.on("zoom", zoomed);
canvas.call(zoom);
function zoomed() { // our handler code goes here }
首先,你使用d3.zoom()
创建缩放行为,定义介于0.5和4之间的缩放限制,并通知行为在触发“缩放”事件时立即触发我们的zoomed()
处理程序。然而,到目前为止,这只是一个粗糙的工具。要了解它做什么,你必须在一个元素上调用它。在你的画布元素上调用它,那么这个元素将成为所有与缩放相关的用户事件的传感器。重要的是,它将监听鼠标滚轮和拖动事件,并在全局的d3.event
对象中暴露事件信息。它还将存储在它被调用的基本元素中的信息(在我们的情况下,是主画布),但我们将由可以在每个事件中访问的d3.event
对象所服务。
此外,我们还想在缩放期间设置一些变量来跟踪我们的比例和旋转位置。我们在代码的顶部这样做,使用以下全局变量:
var width = 960,
height = 600,
projectionScale = origProjectionScale = height / 2.1,
translation = [width / 2, height / 2],
projectionScaleChange,
prevTransformScale = 1,
rotation;
上述代码中的新变量是origProjectionScale
、projectionScaleChange
、prevTransformScale
和rotation
。它们的作用将在接下来的段落中变得清晰。
处理缩放和旋转
我们设置了缩放行为,这意味着我们的画布
-
监听每个鼠标滚轮和拖动
-
在每个这些事件上触发
zoomed()
处理程序
现在让我们填充我们的处理程序,对地球进行一些操作。
我们想做什么?从鸟瞰的角度来看,对于每次缩放,我们想要为投影建立比例,将其应用于路径,并稍微放大或缩小地球。对于每次拖动,我们想要建立新的旋转值,将其应用于投影和路径,并稍微旋转地球。为了达到这个目的,处理程序应该区分缩放和拖动。缩放应该导致投影比例的变化,而拖动应该导致旋转的变化。对于每个路径,你计算位置变化。一旦完成,你需要重新绘制地球。这是游戏循环的咒语:处理用户输入,清除画布,然后使用更新后的数据重新绘制画布。
让我们从缩放动作开始:
function zoomed() {
var event = d3.event.sourceEvent.type;
if (event === 'wheel') {
var transformScale = d3.event.transform.k;
projectionScaleChange = (transformScale – prevTransformScale) *
origProjectionScale;
projectionScale = projectionScale + projectionScaleChange;
projection.scale(projectionScale);
prevTransformScale = transformScale;
} else if (event === 'mousemove'){
// Here goes the rotation logic as this will be triggered upon dragging
}
requestAnimationFrame(function() {
renderScene(countries);
});
}
首先,我们需要区分缩放和拖动事件。D3 通过d3.event
对象持有sourceEvent
属性来指定用户触发的事件type
,使我们很容易做到这一点。如果是wheel
事件,我们改变比例;如果是mousemove
事件,我们改变旋转。很简单。
改变比例看起来很复杂,但实际上相当简单。在我们深入代码之前,让我们做一个重要的区分。投影有一个比例,用户缩放时的变换也有一个比例。然而,它们是不同的。投影比例在不同投影之间是不同的。我们的d3.geoOrthographic()
投影的初始比例大约是286(我们将其设置为height / 2.1 = 286)。我们的变换的初始比例是1。这是默认值。
因此,您可以通过d3.transform.k
检索当前的transformScale
。您记录下这个比例与之前变换比例的变化,这可能对于放大是负数,对于缩小是正数。然而,由于您的投影比例是一个相当大的数字(例如,初始为286)并且变换比例的变化每次缩放都会很小(对于正常的鼠标滚轮转动,变化可能大约是0.005),您可能希望将这个数字放大以在投影中获得明显的变化。因此,您需要将它乘以一个较大的数字。您可以选择任何您喜欢的较大数字,但选择我们称为origProjectionScale
的初始投影比例可以使您将这个计算转移到任何其他投影上,并且应该工作得很好。然后,您只需通过这个projectionScaleChange
更改当前的projectionScale
。
其余的都是简单的。只需使用projection.scale(projectionScale)
将其应用于地球的投影,将之前的变换比例设置为更新的变换比例,并重新渲染地球。注意,您不需要更新路径生成器,因为每次调用它时,它都会使用调用时的投影,我们已经相应地更改了它。
这是最困难的部分。旋转甚至更简单。您只需要跟踪用户鼠标移动的变化并将其应用到 D3 的projection.rotate()
参数上。让我们在zoomed()
处理器的顶部跟踪鼠标坐标的变化:
function zoomed(
var dx = d3.event.sourceEvent.movementX;
var dy = d3.event.sourceEvent.movementY;
// all the rest
注意,Safari 或 Internet Explorer 中不可用两个MouseEvent
属性.movementX
和.movementY
。您可以在代码示例10_02.html中看到一个跨浏览器的实现,该示例在github.com/larsvers/Learning-D3.js-4-Mapping
计算这两个值。
旋转逻辑将在用户拖动或触发mousemove
事件时触发,这进入我们条件语句的else if
部分:
if (event === ‘wheel’) {
// here goes the zoom logic described previously
} else if (event === ‘mousemove’) {
var r = projection.rotate();
rotation = [r[0] + dx * 0.4, r[1] - dy * 0.5, r[2]];
projection.rotate(rotation);
} else {
console.warn('unknown mouse event in zoomed()'); // alerting issues
}
在前面的代码中,我们首先从投影变量r
中检索当前的旋转值。然后,通过鼠标坐标的 x 变化改变r[0]
,即偏航值(负责围绕其法线或垂直轴旋转世界),通过鼠标坐标的 y 变化进一步改变r[1]
,即翻滚值(围绕横向轴旋转世界,从左到右水平移动)。我们保留第三个俯仰值不变,并且是的,最佳方式是将这些值通过dx * 0.4
和dy * 0.5
分别限制在一个合理的旋转速度。请注意,这是旋转地球的直接但天真方法。在我们的情况下,这完全足够。如果你想应用极致的精度,可以使用向量拖动(查看tiny.cc/versor
)。关键区别在于,即使地球倒置,向量拖动也能使地球向正确的方向旋转。
旋转就到这里。记住,在这个条件之后,世界会被重新渲染,因为我们这样做是在接下来的 Canvas 游戏循环中:获取用户输入 - 计算新位置 - 重新渲染。
这里尝试静态展示动态缩放和旋转:
缩放和旋转 Canvas 地球
在浏览器中查看前面截图所示的步骤,网址为larsvers.github.io/learning-d3-mapping-10-2
,以及其代码示例在10_02.html。
通过投影变化进行缩放的主要好处是它允许旋转(这是一个优点)并保证世界的语义缩放而不是几何缩放。当你使用context.scale()
对 Canvas 对象进行缩放时,它会天真地放大 Canvas 上的任何内容。例如,国家边界会随着缩放程度的增加而越来越宽。这就是几何缩放。然而,我们希望保持一切恒定,除了个别国家多边形的面积。这被称为语义缩放。投影变化的另一个好处是,通过鼠标悬停获取 Canvas 对象的坐标更加直接。这是我们下一步要做的。
找到鼠标下的 Canvas 对象 - 选择
我们已经完成了缩放和旋转。让我们通过添加另一个关键交互性元素来庆祝:鼠标悬停。实际上,我们不仅仅想要任何鼠标悬停。我们想要鼠标悬停在 Canvas 上绘制的对象上,并从该对象中检索信息。一旦我们有了这些,我们就有很多——我们可以创建工具提示,我们可以突出显示对象,我们可以将视图与显示相同数据点的另一个图表链接,等等。
选择,理论
那么,我们是如何做到这一点的?如上所述多次建立,我们不能仅仅向一组像素添加监听器,因为事件是浏览器维护的对象,与 DOM 节点交互。然而,我们的浏览器不知道像素。它没有它想要与之交互的画布像素的表示。那么,怎么办?
答案相对简单:我们自己去构建。不是 DOM,那将是疯狂的行为,而是我们画布绘制的表示,其中目标对象的像素被赋予这个对象的信息。
那么,我们需要构建自己的小视觉对象表示?简而言之,你将构建两个画布。一个主画布用于生成我们的视觉(已经完成)和一个隐藏画布(就像你看不见它一样),用于生成相同的视觉。关键在于,第二个画布上的所有元素相对于画布原点与第一个画布上的位置相同。在实践中,我们会稍微弯曲这个规则,但,现在,想象一下苏格兰的北端在主画布的像素位置250, 100,并且它也在隐藏画布的250, 100位置。
主画布和隐藏画布之间只有一个关键区别。隐藏画布上的每个元素都将获得独特的颜色。更重要的是,这些颜色值将是查找我们的数据值的索引。在我们的情况下,我们将rgb(0,0,0)分配给国家列表中的第一个国家:阿富汗。我们的第二个国家将获得颜色值rgb(1,0,0),以此类推,直到我们的最后一个国家——津巴布韦——将获得颜色值rgb(176,0,0)。
为什么?因为,接下来,我们将为主画布附加一个 mousemove 监听器,以获取我们移动鼠标时的鼠标位置流。在每个鼠标位置,我们可以使用画布的自身方法context.getImageData()
来获取此确切位置的像素颜色。我们只需从我们的 RGB 颜色中提取 R 值,然后查询我们的数据数组以获取所需的对象。
我们的行程清晰,并且,通过三个步骤,相对较短。首先,我们将创建隐藏画布。其次,我们将用每个国家的独特颜色绘制世界。最后,我们将编写 mousemove 处理程序来拾取颜色并获取数据。最后,我们必须决定如何处理我们可以访问的所有数据。
在我们开始之前,让我们确保我们确实为每个国家有一些数据。这是我们的 GeoJSON 国家对象,显示了 177 个国家中的前两个国家的数据内容:
国家数组的属性
我们的 GeoJSON 世界是一个FeatureCollection
,每个国家有一个特征,按国家名称升序排序。每个特征是一个包含type
属性、国家多边形的geometry
和称为properties
的属性的对象。在这里,我们有三个数据点:国家缩写、国家名称,甚至还有国家人口的估计。现在,让我们通过鼠标悬停来获取这些数据。
创建所有隐藏的事物
到现在为止,你已经设置了这么多 Canvas,在最坏的情况下,这段代码只会让你感到无聊:
var hiddenCanvas = d3.select('#canvas-container').append('canvas')
.attr('id', 'canvas-hidden')
.attr('width', width)
.attr('height', height);
var hiddenContext = hiddenCanvas.node().getContext('2d');
我们在这里唯一想要确保的是应用与主 Canvas 相同的宽度和高度。
接下来,我们将绘制世界地图。为了做到这一点,我们必须构建一个投影和路径生成器,然后遍历所有国家,将每个国家绘制到 Canvas 上;让我们这样做:
var hiddenProjection = d3.geoEquirectangular()
.translate([width / 2, height / 2])
.scale(width / 7);
var hiddenPath = d3.geoPath()
.projection(hiddenProjection)
.context(hiddenContext);
我们当然需要一个新路径生成器,因为我们需要将现在隐藏的绘图上下文传递给.context()
方法。然而——等等——我们已经有了一个主 Canvas 的投影。我们不应该也使用它来绘制隐藏 Canvas 吗?特别是,正如我们上面所说的,理想情况下,隐藏 Canvas 上的对象应该与主 Canvas 上的对象在确切的位置上,以便轻松查询隐藏位置?然而,在这里,我们使用了一个等距投影,这将以与我们主 Canvas 上的正射投影截然不同的方式绘制世界。我们不需要相同的投影来生成相同的地球吗?
答案是否定的,我们不需要相同的投影。当我们的鼠标位于主 Canvas 上的特定位置时,我们只需要找到隐藏 Canvas 上相同的位置。毫无疑问,最简单的方法是使用确切的相同坐标。然而,我们也可以使用主投影的projection.invert([x,y])
函数来检索该位置的经纬度值。然后我们将使用隐藏投影将地理坐标转换为隐藏 Canvas 上的像素坐标。冗长吗?是的,有一点。然而,对于像缩放和旋转的地球这样的移动对象,这可以让我们避免重新绘制隐藏 Canvas。我们将在第三步构建处理程序时很快看到这一点。
首先,让我们绘制隐藏 Canvas。
绘制隐藏 Canvas
在这一步中,只有一个简单的函数可以完成你需要的功能:
function drawHiddenCanvas(world) {
var countries = world.features;
countries.forEach(function(el, i) {
hiddenContext.beginPath();
hiddenPath(el);
hiddenContext.fillStyle = 'rgb(' + i + ',0,0)';
hiddenContext.fill();
});
}
唯一的参数——world
——是我们的 GeoJSON 要素集合。countries
提取出包含多边形和我们所追求的额外数据的国家信息数组。我们遍历所有这些信息,使用hiddenContext
绘制每个国家,并且——最重要的是——我们使用rgb(<country index>, 0, 0)
模式为每个国家分配了一种颜色。
这里就是它!我们 Canvas 视觉的图状结构,代表我们的数据。
到目前为止,它只是一个函数,所以让我们调用它。我们只需要在有数据可用时调用一次drawHiddenCanvas()
。因此,我们进入ready()
函数,并在使用renderScene()
绘制主 Canvas 之后立即调用它:
requestAnimationFrame(function() {
renderScene(countries);
drawHiddenCanvas(countries);
});
这里就是它们;我们的两个世界:
主 Canvas 和隐藏 Canvas
每个国家都有略微不同的颜色,从黑色到红色,或从 rgb(0,0,0) = 阿富汗 到 rbg(176,0,0) = 津巴布韦。你可以看到,字母表中排名靠前的国家——南极洲、澳大利亚、巴西或加拿大——比字母表中排名靠后的国家——美国或俄罗斯——要暗得多。注意,为了演示目的,我们将保持隐藏 Canvas 可见,但在生产中,我们只需添加 CSS 规则 { display: hidden }
来隐藏我们的 Canvas。没有人需要知道我们的这个小技巧。
选择值
到目前为止,你已经拥有了所有实现悬停的工具。现在,你需要让它发生。为了连接所有这些,你需要执行以下步骤:
-
监听主 Canvas 上的鼠标移动。
-
将这些坐标转换到隐藏 Canvas 上的位置。
-
从那个位置选择颜色。
-
提取代表数据数组索引的数据的颜色值。
-
退后一步,想想如何使用它。
监听鼠标移动很简单;你只需要执行以下命令:
canvas.on('mousemove', highlightPicking);
完成。在 highlightPicking()
中,我们首先将主 Canvas 上的鼠标位置转换为隐藏 Canvas 上的坐标:
function highlightPicking() {
var pos = d3.mouse(this);
var longlat = projection.invert(pos);
var hiddenPos = hiddenProjection(longlat);
我们首先获取 x, y 鼠标坐标。每当鼠标移动时,这个坐标将会更新。pos
变量的一个示例值是 [488, 85],这位于法国的北部。我们使用 D3 的 projection.invert()
,它是 projection()
的逆操作。projection()
做什么呢?它接受一个 [经度, 纬度] 值的数组,并返回一对 [x, y] 像素坐标。嗯,projection.invert()
做的是相反的操作。它接受一个像素坐标数组,并返回相应的经纬度数组。在我们的例子中,这将是一个 [2.44, 48.81] 的数组。经度稍微偏向 0 的右边,即格林尼治,所以,这似乎是正确的。注意,这个投影是我们主要的 Canvas 投影。接下来,我们使用我们的 hiddenProjection()
函数将 longlat
值重新投影到这个位置的像素坐标。在我们的例子中,hiddenPos
被分配了像素坐标 [485.83, 183.17]。这就是法国北部的同一位置在隐藏的 Canvas 上!这正是我们想要的。
为了演示这一点,请看以下截图:
将主 Canvas 鼠标坐标转换为隐藏 Canvas 坐标
我们在主 Canvas 上表示的鼠标位置 pos
被转换成表示为 hiddenPos
变量的下方的橙色圆圈。
现在,我们终于可以挑选那个颜色了:
var pickedColor = hiddenContext.getImageData(hiddenPos[0], hiddenPos[1], 1, 1).data;
这返回了一个名为 Uint8ClampedArray
的特殊数组,这个名字听起来很复杂,它代表了在精确像素处的 R、G、B 和 alpha 值(特别地,这些值也范围从 0 到 255)。在我们的例子中,例如,对于法国(前一个截图中最左侧的选择),颜色是 52
:
选择的颜色数组
通过与我们的countries
数组进行交叉检查,我们可以确认索引为52
的数组元素是法国。
然而,在我们能够确信鼠标悬停在某个国家之前,我们将设置两个安全检查。首先,你需要检查用户的鼠标是否在地球上,而不是在太空中某个地方:
var inGlobe =
Math.abs(pos[0] - projection(projection.invert(pos))[0]) < 0.5 &&
Math.abs(pos[1] - projection(projection.invert(pos))[1]) < 0.5;
在一个理想的世界里,为了我们的目的,projection.invert(pos)
上面在移动到地球之外时会返回undefined
或类似值;然而,它仍然返回实际的像素坐标,这并不是我们想要的。问题是projection.invert()
不是双射的,这意味着它可以实际上为不同的像素位置输入返回相同的[经度, 纬度]坐标。这在我们移动鼠标超出地球边界时尤其如此。为了减轻这个问题,我们在这里进行所谓的正向投影。这仅仅意味着我们投影投影的反向。我们接收像素坐标,将它们转换为[经度, 纬度]值,并将它们重新投影回像素坐标。如果我们的鼠标在地球内,这将返回我们的确切鼠标位置(实际上我们在这里给了它+/- 0.5 像素的容差)。如果鼠标在地球外,正向投影将偏离鼠标的像素位置。
我们进行的第二次检查是确保鼠标位于一个国家而不是国家边界上:
selected = inGlobe && pickedColor[3] === 255 ? pickedColor[0] : false;
让我们逐一来看。selected
将保存索引。然而,你只有在用户的鼠标在地球内(inGlobe
=== true
)时才会得到索引。这是我们的第一个检查。其次,我们特殊pickedColor
数组的第四个元素必须正好是255。否则,selected
将为false
。这个第二个检查是为了超越抗锯齿效果。
我们为什么需要这样做呢?浏览器中像素的问题在于它们比我们聪明。边缘的线条被羽化,以允许从线条到背景的平滑过渡印象:
在抗锯齿线之上的别名线
在这些羽化边缘选择值不会返回完全不透明的颜色,而是不同程度的透明值。这些值具有低于 255 的 alpha 通道,因此检查我们的 alpha 是否为 255 可以让我们只从别名区域中选择。
太棒了!我们已经为自己构建了一个第二个 Canvas,它充当了我们主要数据上对象的记忆。接下来,我们将使用它。Canvas 改变任何元素和对象的方式是将信息传递给我们的应用程序的重绘部分,以便在那里相应地使用它。
存储更多数据和使用查找数组
我们很幸运,因为我们可视化的世界只有 176 个国家。这样,我们只需要跟踪 176 个索引。然而,你经常处理更多的数据对象,所以 256(就像 0-255)会很快用完。幸运的是,我们不仅有 R、G 和 B 值及其独特的组合,这使我们达到了 256256256 = 16,777,216 个可能的索引可以存储。这将带你走得很远。
查看教程tiny.cc/d3-canvas
以获取更多详情。
鼠标悬停时突出显示国家
每当选择一个国家时,我们只需将选定的变量传递给我们的 drawScene()
函数,该函数在每个鼠标悬停时绘制世界:
// ...
selected = inGlobe && pickedColor[3] === 255 ? pickedColor[0] : false;
requestAnimationFrame(function() {
renderScene(countries, selected);
});
} // highlightPicking()
在我们的高亮处理程序结束时,我们不仅将 countries
传递给我们的渲染函数,我们还沿着路发送了我们新创建的 selected
。renderScene()
函数只是将其传递给 drawScene()
,该函数将世界绘制到 buffer
Canvas 上。记住,renderScene()
只是调用 drawScene()
,然后清除主 Canvas,并将 buffer
图像复制到主 Canvas 上。
在 drawCanvas()
中,我们将添加一个单独的块:
function drawScene(countries, countryIndex) {
// Clear …
// Sphere fill …
// Grid …
// Country fill …
// Country stroke - each country ….
// Country stroke - hovered country
if (countryIndex >= 0) {
bufferContext.beginPath();
bufferContext.setLineDash([4,2]);
bufferPath(countries.features[countryIndex]);
bufferContext.lineWidth = 1;
bufferContext.strokeStyle = '#777';
bufferContext.stroke();
bufferContext.setLineDash([]);
}
}
我们将通过 countryIndex
参数接收 selected
索引,并检查它是否大于或等于 0(记住,那将是 阿富汗)。如果是这样,我们将在国家周围绘制一条虚线。我们如何知道是哪个国家?我们通过 countries.features[countryIndex]
访问正确的国家,并相应地绘制它。这让人感到困惑:
美国周围的一条虚线,仅仅因为我们选对了颜色
在浏览器中查看此步骤larsvers.github.io/learning-d3-mapping-10-3
和代码示例10_03.html。
按国家可视化数据和添加工具提示
你学会了如何构建一个数据驱动的和给予的视觉表示。你也用它来突出显示鼠标悬停时的国家。然而,你还没有真正挖掘出这个交互选项的丰富性。让我们现在就做这件事。你可以做很多事情,但我认为工具提示是一个合理的起点。
在我们开始构建工具提示之前,让我们给地球添加一些更有趣的数据。到目前为止,我们有 国家名称、国家名称缩写和人口估计。这已经是有东西可以工作了。然而,让我们给自己一个任务,向我们的地球添加一个额外的数据源,适当地可视化它,并为用户探索添加合理的交互。
作为一个小提醒,这就是你将要构建的内容:
我们的最终 Canvas 冒险
在larsvers.github.io/learning-d3-mapping-10-4
查看最终应用程序,并在10_04.html查看代码示例。
前面的截图显示了按国家森林覆盖率的地球可视化。数据来自所有智慧之源,维基百科。建议将其可视化为一个等值线图。虽然近年来等值线图可能被过度使用,但它们无疑是一个展示地理区域百分比比较的好选择。
数据来自en.wikipedia.org/wiki/List_of_countries_by_forest_area
。北塞浦路斯、索马里兰和海地的数据已估算。
这些步骤相对简单。首先,我们将森林数据添加到我们的 GeoJSON 世界对象中。我们将迅速继续根据新数据为各国着色,并最终添加带有 HTML 和 SVG 的提示信息。
向我们的旧地球添加新数据
在复制粘贴或从维基百科抓取数据后,您应该将森林文件保存为您选择的数据格式。我们将其保存为 CSV,因为我们现在有多个数据源需要加载以创建一个视觉图表,我们将使用d3.queue()
等待两个文件加载完毕后再调用ready()
:
d3.queue()
.defer(d3.json, 'data/world-110.json')
.defer(d3.csv, 'data/forests.csv')
.await(ready);
然后,根据需要调整ready()
函数的参数并开始:
function ready(error, world, forests) {
if (error) throw error;
保持国家数据准备状态不变(即我们将世界国家的数组推入一个名为countries
的变量中)并继续将森林数据包含到世界中。我们想要的是这个:
我们努力追求的更新后的数据对象
我们需要用于着色和提示的属性是国家名称(admin
)、forest_percent
和forest_area
。注意,我们这里也有forest_color
。这是该国家的等值线颜色。在绘图之前将数据准备好通常是有益的。在重绘期间进行大量计算可能会降低性能并重新渲染。
森林 CSV 国家的名称已更改为与 GeoJSON 中国家的确切命名相匹配。这样,您可以使用这些名称将两个数据集连接起来。为了快速连接数据,我们将使用二分查找。二分查找利用我们countries
数组的排序特性快速找到匹配的国家。简而言之,它会查看我们想要找到的国家名称,而不是在 GeoJSON 中遍历所有国家,而是将countries
数组分成两半,并检查搜索项是否在上半部分或下半部分。它会重复这样做,直到找到该术语。这比线性搜索(遍历所有数据)快得多;在我们的案例中,大约快 10 倍。
你可以使用 D3 中的d3.bisect()
实现二分搜索,这正是我们将要使用的。我们通过一个我们称之为insertForestDataBinary()
的函数添加数据。我们将在ready()
函数中将此函数调用和函数添加到数据准备流程中:
function insertForestDataBinary() {
var bisectName = d3.bisector(function(d) { return d.properties.admin;
}).right;
for (var i = 0; i < forests.length; i++) {
var indexBisect = bisectName(countries.features, forests[i].country);
var indexMatch = indexBisect - 1;
countries.features[indexMatch].properties.forest_area = +forests[i].area;
countries.features[indexMatch].properties.forest_percent =
+forests[i].percent;
countries.features[indexMatch].properties.forest_color =
colorScale(+forests[i].percent);
}
}
首先,你创建一个bisector函数,这样 D3 就知道我们想要找到哪个变量的名称(d.properties.admin
,国家名称)。然后,你遍历所有森林对象。每个森林对象包含country
(我们要匹配的名称)、forest_percent
和forest_area
属性。二分搜索将搜索数组,并在匹配的countries
对象之后返回索引(或到.right
,如我们上面指定的)。一旦你有了这个,你就可以在索引位置之前添加新的属性。
对于最后一个属性,forest_color
,你需要在更高作用域的某个地方创建一个colorScale
:
var colorScale = d3.scaleSequential(d3.interpolateYlGn).domain([0,1]);
为地球着色
注意,你在绘制地球之前已经实现了所有这些更改。这很好,因为你现在可以简单地使用新的颜色方案绘制它。唯一的变化是在我们的drawScene()
函数中,在循环中相应地填充countries
:
function drawScene(countries, countryIndex) {
// Clear the rect, draw the sphere and the graticule
// Country fill - individual
countries.features.forEach(function(el) {
bufferContext.beginPath();
bufferPath(el);
bufferContext.fillStyle = el.properties.forest_color;
bufferContext.fill();
});
// Draw the country stroke…
}
此外,请注意,我们对球体填充和网格线颜色做了一些调整,以更好地与我们的黄色-绿色国家颜色尺度相匹配:
一个表示每个国家森林覆盖率比率的等值线地球视觉
添加工具提示
你的地球通过森林覆盖率着色。黄色国家覆盖率低;深绿色覆盖率较高。这已经是一个很好的线索,说明了每个国家森林的比例。然而,用户可能还想知道森林覆盖的确切程度,以及这与其他国家相比如何。你手中拥有所有数据,所以让我们不要吝啬,添加以下工具提示:
我们的工具提示
工具提示中的视觉显示了一个所有国家的排序条形图、森林覆盖率百分比,以及一个表示悬停国家在整体分布中位置的红色指示器。
HTML
这很简单,如下所示:
<div id="tooltip">
<div id="tip-header">
<h1></h1>
<div></div>
</div>
<div id="tip-body">
<svg id="tip-visual"></svg>
</div>
</div>
一个工具提示包装器div
,一个带有h1
标题的国家头部,以及一个div
来存放信息。以下是一个包含用于存放条形图的 SVG 元素的正文。注意,我们在画布上添加了 HTML 和 SVG 元素,这当然没问题。我们甚至可以在画布元素上绘制 SVG 元素,反之亦然。
构建工具提示的静态部分
接下来,我们将构建工具提示。更准确地说,我们将构建工具提示的静态部分,即条形图。我们将添加在悬停国家时立即出现的可变部分,如标题信息和红色指示器。首先,我们将数据扭曲成正确的形状,然后构建一个简单的条形图:
function buildTooltip(data) {
var forestsByPercent = data
.slice()
.sort(function(a, b) {
return d3.descending(+a.percent, +b.percent);
})
.map(function(el) {
return {
country: el.country,
percent: +el.percent,
color: colorScale(+el.percent)
};
});
var countryList = forestsByPercent.map(function(el) {
return el.country;
});
我们传递给这个函数的数据是——正如你所猜到的——我们的林业增强国家的 GeoJSON。forestsByPercent
只是一个包含我们需要的用于条形图数据的对象的排序数组。countryList
只是一个(也排序的)countries
数组,我们将将其用作我们的序数刻度的扩展。以下就是生成的条形图:
var tipWidth = 200,
tipHeight = 200;
var xScale = d3.scaleLinear()
.domain([0, 1])
.range([0, tipWidth]);
var yScale = d3.scaleBand()
.domain(countryList)
.rangeRound([0, tipHeight]);
svg = d3.select('svg#tip-visual')
.attr('width', tipWidth)
.attr('height', tipHeight);
svg.selectAll('.bar')
.data(forestsByPercent)
.enter().append('rect')
.attr('class', 'bar')
.attr('id', function(d) { return stripString(d.country); })
.attr('x', xScale(0))
.attr('y', function(d) { return yScale(d.country); })
.attr('width', function(d) { return xScale(d.percent); })
.attr('height', yScale.bandwidth())
.attr('fill', function(d) { return d.color; });
} // buildTooltip()
这很简单。顺便说一下,我们在ready()
函数中构建了我们所有的交互式工具提示函数。这样,我们可以访问我们需要的所有数据,并且将它们很好地隔离在任何外部 JavaScript 作用域之外。在现实生活中,可能值得考虑将交互性外包给其自己的模块,以保持关注点的分离。
我们在定义了一个可以从其他两个工具提示函数tooltipShow()
和tooltipHide()
中访问的svg
变量后,在ready()
函数中调用这个buildTooltip()
函数,这两个函数我们将在后面构建。
var svg;
buildTooltip(forests);
显示和隐藏工具提示
我们需要一个小的逻辑块来告诉我们的应用程序何时显示工具提示,何时隐藏工具提示。使用 SVG,这个逻辑通常很简单,因为我们可以利用 mouseover 和 mouseout。使用 Canvas,我们实际上只有整个 Canvas 上的 mousemove。因此,我们构建了自己的 mouseover 和 mouseout 逻辑。我们从名为highlightPicking()
的 mousemove 处理程序开始:
function highlightPicking() {
// Here, you find the country index and store it in pickedColor
// and you check if the user’s mouse is in the globe or not with inGlobe
selected = inGlobe && pickedColor[3] === 255 ? pickedColor[0] : false;
requestAnimationFrame(function() {
renderScene(countries, selected);
});
var country = countries.features[selected];
if (selected !== false) showTooltip(pos, country); // build tooltip
if (selected === false) hideTooltip(); // remove tooltip
}
你可以将鼠标悬停的国家的数据存储在country.
中。如果selected
包含一个数字(一个国家索引),我们将触发名为showTooltip()
的创意函数,并传递主画布的鼠标位置和国家。如果selected
返回false
,则鼠标没有悬停在某个国家上,我们将触发同样具有创意的函数hideTooltip()
。
在showTooltip()
中,你需要弄清楚何时构建一个新的工具提示,何时只是移动现有的工具提示。当鼠标从一个国家移动到另一个国家时,你想要构建一个新的工具提示。当鼠标在特定国家的边界内时,你只想将工具提示随鼠标移动。
我们将通过一个像队列一样工作的数组来实现这一点。你可以想象一个堆栈垂直站立,只能将新数据添加到顶部或从顶部移除数据。相比之下,你可以想象一个像冰淇淋店前排队一样水平的队列。人们从队列的后面到达,从队列的前面离开。
我们的队列将只有两个人长。实际上,它不会是两个人长,而是两个国家长。每次我们移动鼠标,队列都会将我们所在的国家的名字添加到队列的一侧(实际上是在前面),立即将另一侧(在后面)的国家推出。当我们从美国的一个地方移动到另一个地方时,它会说[“United States of America”, “United States of America”]
。一旦我们的鼠标轻松地移动到墨西哥,它就会在队列的前面添加“Mexico”
,将之前0
索引的“United States of America”
推到索引位置1
,并在那里切断数组。现在,我们有一个包含[“Mexico”, “United States of America”]
的数组。
现在检查我们是否更改了国家变得简单,只需比较队列中的两个值。如果它们相同,我们只需移动鼠标;如果不同,我们为墨西哥创建一个新的工具提示。
这就是为什么在应用程序交互频繁时,SVG 或 HTML 通常比 Canvas 更受欢迎的典型例子。尽管如此,这并不太糟糕,对吧?让我们来实现它。首先,你需要定义你的空队列:
var countryQueue = [undefined, undefined];
然后,你需要编写showTooltip()
函数,它接受鼠标位置和元素,即鼠标所在的那个国家:
function showTooltip(mouse, element) {
var countryProps = element.properties;
countryQueue.unshift(countryProps.admin);
countryQueue.pop();
你将国家的数据保存在countryProps
中,使用 JavaScript 的.unshift()
方法将国家的名字添加到队列的前面,然后使用pop()
方法从队列中移除最后一个值。
然后,我们将确定是否有国家变更:
if (countryQueue[0] !== countryQueue[1]) {
var headHtml =
'Forest cover: ' + formatPer(countryProps.forest_percent) + '' +
'<br>Forested area: ' + formatNum(countryProps.forest_area) + '
km<sup>2</sup>';
d3.select('#tip-header h1').html(countryProps.admin);
d3.select('#tip-header div').html(headHtml);
svg.selectAll('.bar').attr('fill', function(d) { return d.color; });
d3.select('#' + stripString(countryProps.admin)).attr('fill', 'orange');
d3.select('#tooltip')
.style('left', (mouse[0] + 20) + 'px')
.style('top', (mouse[1] + 20) + 'px')
.transition().duration(100)
.style('opacity', 0.98);
如果有,你将工具提示的标题填充上特定国家的信息。在为这个特定国家的条形图着色红色之前,你也会根据适当的颜色为所有条形图着色。其余的只是随着鼠标移动移动提示,并提高其透明度以使其可见。
If the queue values are the same, you just move the tip:
} else {
d3.select('#tooltip')
.style('left', (mouse[0] + 20) + 'px')
.style('top', (mouse[1] + 20) + 'px');
}
}
记住,showTooltip()
每次鼠标悬停在某个国家上时都会显示,并且我们的selected
变量会填充一个国家索引。如果selected
为假,我们知道我们不在国家上,这意味着我们想要移除我们的工具提示。我们用hideTooltip()
来做这件事:
function hideTooltip() {
countryQueue.unshift(undefined);
countryQueue.pop();
d3.select('#tooltip')
.transition().duration(100)
.style('opacity', 0);
}
我们决定,如果我们不在一个国家上,就适当地将undefined
分配给队列,所以我们将其unshift()
到队列的前面,并pop()
掉数组的最后一个值,以确保我们总是可以比较下一移动的成对数据。最后,我们将透明度过渡回零,它又消失了。就是这样!全部完成。
摘要
你已经正式看到并使用了 Canvas。你享受了它的辉煌时刻,掌握了它的怪癖。你从一个皇家蓝色的矩形开始,现在已经成功地构建了一个整个世界,你可以旋转它,按需调整大小,并检索特定国家的信息。在这个过程中,你也看到了 Canvas 与 SVG 相比的工作方式。你了解了在接近机器图形处理部分编码时的好处和问题。
当然,这些章节的目的是扩展你的技术技能集。然而,除此之外,它还提出了如何接近 Canvas 的替代概念——过程式风格、游戏循环常规以及 Canvas 与 D3 交互的方式——这些内容拓宽了作为开发者的视野,并允许从不同的角度来解决问题。
第十一章:使用数据塑造地图 - 六边形地图
不同类型的数据适合不同的可视化。当你想展示时间线时,你很少构建垂直条形图。你更有可能使用水平线形图。当然,在将数据编码到位置、形状或颜色时,你应该给自己一些表达的自由。然而,手头的数据、你想要传达的意义以及认知解码过程在决定如何编码你的数据时是非常重要的指南。
在本章中,我们将专注于一种特定的地图可视化技术:六边形分箱地图(六边形地图)。在专注于六边形地图之前,我们将简要回顾各种地图可视化技术。你将了解六边形地图的概念和认知优势,与其他形状相比,六边形有什么用途,以及它们是如何计算的。
然而,本章的大部分内容将是实践性的,从头开始构建六边形地图。大部分注意力将集中在数据准备和塑形上。D3 的D3-hexbin模块将使实际的可视化变得轻而易举。我们将关注一系列数据准备和可视化任务,这些任务旨在易于遵循。让我们开始吧!
回顾地图可视化技术
表示地理数据的方法有很多。不出所料,地图经常被用到。虽然地图是一种吸引人的方式来展示数据,大多数人可以轻松解读,但它们可能会被过度使用。如果你想展示哪个国家森林覆盖率最高,你可能会选择展示一个地球仪并使用颜色饱和度来编码森林比率。或者,你可以展示一个排序的垂直条形图,显示森林覆盖率最高的国家在最上面,最低的国家在最下面。地图版本可能看起来更漂亮,并给你的用户一个关于森林缺乏或丰富的位置的直观感受。然而,条形图提供了对森林覆盖率分布和各国比较的更简洁的概述。
因此,让我们假设你已经决定使用地图作为你视觉表示的基本形式。有哪些选择呢?
着色图
广为人知且可能被过度使用的着色图,如果你需要比较地理单元(如州、县或国家)之间的标准化比率时是一个不错的选择。你在第四章“创建地图”和第十章“为你的画布地图添加交互性”中构建了着色图,比较了每个国家的森林覆盖率比率
你唯一可以编码你选择的度量的视觉通道是颜色。这些单位的面积已经由这些单位的大小给出。这可能会将用户的注意力从较小的单位转移到较大的单位上。以我们的森林为例,像美国、俄罗斯或巴西这样的大国可能会比像卢森堡、海地或伯利兹这样的小国得到更多的初始关注。
为了减轻这种注意力问题,你应该在可视化的度量中对每个国家都公平。关键规则是不要可视化绝对数字,而是与国家相关的标准化比率。我们在森林示例中遵循了这一规则,通过可视化森林覆盖面积占总国家面积的百分比。这个度量对每个国家都有相同的范围,独立于国家的面积(0 到 100%)。这是一个标准化且因此公平的度量。树木的绝对数量将是一个不公平的度量。一个树木较少的大国可能仍然比一个树木繁茂的小国拥有更多的树木,这使得我们的比较变得有问题的甚至毫无意义。
此外,地理单位应该定义你可视化的度量。例如,税率是由国家制定的,跨国家比较非常有意义。森林覆盖率并不是(完全)由一个国家的行动和政策决定的,因此在渐变图中展示它意义不大。国家的行动仍然会影响其森林覆盖率,所以我不会完全忽视它(例如,多米尼加共和国对其森林的管理方式比邻国海地更为保守),但这应该是你选择可视化技术时的一个有意识的部分。
由于渐变图如此普遍,让我们看看另一个不同数据的例子:美国的农场市场。它们将在本章的剩余部分陪伴我们,所以这是一个深入了解的好时机。
我们将使用的农场市场数据由美国农业部发布于www.ams.usda.gov/local-food-directories/farmersmarkets
。经过一番清理后,我们得到了一个包含美国大陆 8,475 个市场的数据集。每个市场都有一些有趣的变量,包括我们可以用于地图的经纬度值,以及它们所在的名字、州和城市。它还包括 29 个二元变量(即是/否),表示每个市场正在销售的产品。我们将在以后使用这些变量来可视化市场的子集。
这里是一个美国各州的渐变图(仅包括大陆部分以保持简单)。它显示了每 10 万人中的农场市场数量。浅蓝色表示市场少;深蓝色表示每 10 万人中有许多市场:
农场市场渐变图
这里有一个问题需要考虑,即按州比较农贸市场是否有很大意义。州政策或文化在促进或反对农贸市场中发挥作用吗?也许吧。然而,一旦我们决定进行州级比较,我们能否很好地进行比较?德克萨斯州由于其规模在视觉上占据了很大的权重,这暗示了南部农贸市场的匮乏。我们可以看到佛蒙特州的比例最高(我们用红线指向它也有帮助),但华盛顿特区呢?每 10 万人有 8.5 个市场?我们甚至无法在地图上看到它。
地形图
地形图通过将你的值编码到面积大小来消除面积问题。我们的农贸市场地形图将比例映射到颜色和面积大小,看起来如下:
农贸市场的连续地图
你的地图的面积——以及随之而来的形状——以这种方式扭曲,以至于面积代表了你想要可视化的值。这对华盛顿特区来说很棒,它被显著放大,以便被识别为农贸市场的重量级市场。简而言之,它们解决了注意力盗窃的 choropleth 问题,但在我们的地理单位往往难以辨认的情况下,又产生了新的问题。你的用户会很好地接受他们对县、州和国家这种熟悉地区的现实扭曲,但他们会很难理解他们不熟悉形状的地区。这看起来太神秘了,可能会降低可读性,甚至可能导致对兴趣的完全丧失。
连续地图是通过github.com/shawnbot/topogram
生成的。
点密度图
点密度图非常适合你想展示事物数量而不是比例的情况。每个事物在地图上都是一个点。以下是美国所有农贸市场的点密度图:
农贸市场的点密度图
这种可视化技术的优点很明显:它展示了所有数据。你可以轻松地看到它们都在哪里,并检测到全国范围内的农贸市场集群。问题是实际上它并没有展示所有数据。一些点在小而繁忙的地区重叠。第二个潜在问题是许多空间分析中的绝对度量与人口分布高度相关。所以当你想说“看看所有农贸市场的位置”时,你实际上是在说“看看所有人的位置”。这并不意味着你不应该展示它,但你应该意识到这一点。顺便说一句,我们的六边形图也会遇到同样的问题,所以要有意识。展示所有数据的另一个问题是,对于用户来说,查看如此多的数据和元素可能会显得混乱。我们可能希望以更有序的方式将注意力集中在集群上。这就是六边形图派上用场的地方。
注意,这份地图可视化技术列表并不完整。当然,还有其他地图可视化技术,如热力图、聚类图、渐变圆、比例符号或气泡图,以及非连续的地图。一个很好的地方是查看人们如何在地图上可视化的内容是flowingdata.com/category/visualization/mapping/
。
六边形的价值和用途
六边形可以解决我们前面提到的一些问题。它们可以帮助解决等面积问题,并为点簇带来有序的焦点。让我们先看看几个例子:
六边形镶嵌
如你所见,六边形有等长的边,并且可以很好地相邻排列。然而,它们不仅仅是外表漂亮,它们还拥有我们可以在数据可视化中充分利用的特性:
-
六边形将给定区域划分为大小相等的六边形。这被称为镶嵌,也可以用其他形状如圆形、三角形、矩形或其他多边形来完成。
-
然而,如果你用圆形铺满你的墙面,你会在圆形之间留下缝隙。用重复的对称形状无缝覆盖平面称为规则的镶嵌,实际上,这仅适用于正方形、三角形和六边形。
-
在这三个形状中,六边形是最接近圆形的最高边形状。因此,它们最适合表示——分组——一组点。三角形或正方形的角点比六边形的角点离它们的中心更远,这使得六边形天生适合对点数据进行分组。圆形在分组方面是最优的,但再次强调,它们不能进行镶嵌。
让我们再考虑一下分组。分组意味着将数据分组到大小相等的类别中。当我们有一组 100 人的年龄数据时,我们可以查看每个年龄的频率,或者我们将数据分组到更易于消化的年龄组,如 20-39 岁、40-69 岁和 70-99 岁。我们将单个数据点汇总到更大的——通常是大小相等的——组中。
在地图环境中,我们可以将点位置数据分组到大小相等的区域。六边形非常适合这项任务,因为它们很好地分组点,并且在平面上规则地镶嵌。这就是 D3 实现的六边形图能为你做的事情。我们不必像在点密度图中那样,可能将点堆叠在一起,我们可以定义大小相等的六边形区域,将点汇总到一个用颜色编码的汇总度量中。因此,分组比单个点更好地表示每个六边形区域的数据。六边形的镶嵌支持分组,因为它创建了最佳可能的、无缝的、相对公平的分组形状。
在接下来的章节中,我们将非常关注这些六边形散点图,其中每个六边形代表相等的面积。在我们深入探讨六边形散点图之前,让我们快速看一下你可能遇到过的另一种六边形应用:六边形渐变色地图。如上图所示的经典渐变色地图所提出的问题是,像佛蒙特州或华盛顿特区这样的小州很容易被忽视,因为它们的视觉重量很低。其他面积州,如德克萨斯州或蒙大拿州,通过 sheer size 吸引人们的注意。为了解决这个问题,我们可以用六边形替换州多边形:
在六边形渐变色地图中解放美国各州的大小
让我们明确一点,正如前图和以下各节所描述的,六边形散点图中的六边形代表相等的面积。然而,在这个图中,我们不想关注我们选择的单位(美国大陆各州)的空间面积;我们想关注的是仅由我们选择的单位进行分类的度量。
请注意,这会带来完全去除面积信息的代价,因为美国各州的面积差异很大,而且没有任何一个州看起来像六边形。然而,与先前的经典渐变色示例不同,这种六边形渐变色允许我们,例如,轻松地识别华盛顿特区作为一个农民市场中心,这可能是我们最想传达的信息。
理论已经足够。让我们制作一个六边形散点图。
制作六边形散点图
在改进了我们的美国州六边形渐变色地图后,现在让我们使用六边形来改变我们的点密度图。正如我们之前所看到的,点密度图有一些好处,所以我们即将进行的更改更多的是改进而不是明显的提升。以下是我们要构建的内容:
一张多边形六边形的彩色地图
这是一张显示农民市场热点的六边形散点图。没有农民市场的地区以白色六边形表示,拥有许多农民市场的地区以蓝色到深紫色表示。较浅且饱和度较低的黄色和绿色六边形代表市场较少的地区。
检查六边形散点图算法
我们想要达到什么目标?我们想要涵盖两个主要步骤。首先,我们想要展示美国作为一个六边形镶嵌。接下来,我们想要突出显示有农民市场的六边形,并使用颜色编码每个六边形内的市场数量。
或者,我们可能对只展示美国地图并仅显示有农民市场的六边形感到满意。这将涉及较少;然而,为了美观和清晰,走得更远似乎是值得的。
实际绘制六边形散点图很简单,多亏了d3.hexbin()
模块完成了绘制六边形的工作。在制作美国六边形网格时需要更多的关注。但是,不用担心;这个过程很简单,就在这里:
-
绘制地图。
-
在整个地图上叠加一个对称的点网格。
-
只保留在地图边界内的网格点。
-
将网格点数据与我们要可视化的位置数据合并。
-
使用 D3-hexbin 模块计算六边形位置。
-
对于每个六边形,汇总你想要可视化的统计信息。
-
可视化汇总统计(例如,通过颜色编码六边形):
六边形地图演变
设置
设置很简单。你有一个带有 id="vis"
的单个 <div>
用于可视化。在 JavaScript 中,你设置一个全局 projection
变量以填充,并创建一个 svg
元素:
var projection;
var margin = { top: 30, right: 30, bottom: 30, left: 30 },
width = 900 - margin.left - margin.right,
height = 600 - margin.top - margin.bottom;
var svg = d3.select('#vis')
.append('svg')
.attr('width', width + margin.left + margin.top)
.attr('height', height + margin.top + margin.bottom)
.append('g')
.attr('transform', 'translate(' + margin.left + ', ' + margin.top + ')');
绘制地图
如同往常,我们首先将数据放入应用程序中。到目前为止,你只有美国数据;然而,为了预见到农民市场点数据,我们稍后会将其拉入——让我们使用 d3.queue()
来加载数据:
d3.queue()
.defer(d3.json, 'data/us.json')
.await(ready);
ready()
函数在数据加载后异步调用:
function ready(error, us) {
if (error) throw error;
var us = prepData(us);
drawGeo(us);
}
在那里,你检查错误,准备美国数据,并绘制它。数据准备是一行代码,将 topo
转换为 GeoJSON 多边形的数组:
function prepData(topo) {
var geo = topojson.feature(topo, topo.objects.us);
return geo;
}
绘图函数只接受 GeoJSON 作为其唯一参数。创建投影和路径生成器,并绘制美国。projection
是一个全局变量,因为我们稍后将在其他地方使用它:
function drawGeo(data) {
projection = d3.geoAlbers() // note: global
.scale(1000).translate([width/2, height/2]);
var geoPath = d3.geoPath()
.projection(projection);
svg
.append('path').datum(data)
.attr('d', geoPath)
.attr('fill', '#ccc')
}
注意,我们在这里使用的是 d3.geoAlbers()
投影。Albers 投影是一种所谓的等面积圆锥投影,它扭曲了比例和形状,但保留了面积。在制作点密度图或六边形图时,不扭曲扭曲区域中点的感知密度是至关重要的。换句话说,我们的六边形在投影平面上代表相等的面积,因此我们需要确保投影平面使用适当的投影来尊重等面积。请注意,等面积圆锥投影要求地图制作者选择两个平行线(纬度圈)作为投影的基础。d3.geoAlbers
已经预先配置好了,选择了两个平行线 [29.5, 45.5]。这为美国产生了一个优化的投影。当可视化其他国家或地图区域时,你可以使用 .parallels()
方法覆盖它,或者使用 d3.geoConicEqualArea()
投影自行设置。
结果并不令人惊讶:
美国大陆
你可以在浏览器中查看此步骤,网址为 larsvers.github.io/learning-d3-mapping-11-1
。代码示例 ;11_01.html。
在每个步骤结束时,您将在靠近相关图像的信息框中找到两个链接。第一个链接将带您到一个可以在浏览器中查看的此步骤的工作实现。第二个代码示例链接将带您到完整的代码。如果您正在阅读印刷版,您可以在github.com/larsvers/Learning-D3.js-4-Mapping
的相关章节中找到所有代码示例。
在我们继续之前,让我们退一步,看看我们是如何在命令行上生成TopoJSON数据的。原始的美国地图数据来自www.census.gov/geo/maps-data/data/cbf/cbf_nation.html
,并且通过以下六个步骤从 shapefile 转换为 TopoJSON:
- 如果您还没有安装,请安装
shapefile
:
npm install -g shapefile
- 如果您还没有安装,请安装
topojson
:
npm install -g topojson
- 将 shapefile 转换为 GeoJSON:
shp2json cb_2016_us_nation_20m.shp --out us-geo.json
- 将 Geo 转换为 TopoJSON:
geo2topo us-geo.json > us-topo.json
- 压缩数字精度:
topoquantize 1e5 < us-topo.json > us-quant.json
- 简化几何形状:
toposimplify -s 1e-5 -f < us-quant.json > us.json
您可以在bit.do/cl-carto
上了解更多关于命令行制图的信息。
为我们的六边形绘制点网格
我们的目的是在美国地图上绘制一个六边形网格。D3-hexbin 稍后会为我们做这件事,但它只能在有点的位置绘制六边形。因此,我们需要向它提供点。这些点对我们用户的任何信息价值都没有。它们将仅用于生成布局。因此,我们可以区分我们将需要的两种点:
-
布局点用于生成六边形镶嵌
-
数据点用于渲染颜色分级的信息
我们很快就会到达数据点,但在这个阶段,我们只关心我们的布局点。一旦完成,您将产生这个美妙而规则的点模式,它横跨我们的整个绘图区域:
在美国上方的点网格
您可以在浏览器中查看此步骤,请访问larsvers.github.io/learning-d3-mapping-11-2
,以及代码示例在11_02.html。
在下一个步骤中,我们将切割这个网格以适应美国的轮廓,但让我们先布局。请注意,这将是最复杂的计算部分。这不是火箭科学,但如果它一开始没有立即理解,请不要担心。一旦在调试器中逐步通过代码并/或使用几个console.log()
,事情通常会变得清晰。无论如何,我们开始吧:
var points = getPointGrid(160);
getPointGrid()
只接受一个参数:我们想要的点的列数。这足以让我们计算网格。首先,我们将获取每个点之间的像素距离。每个点之间的距离代表六边形中心之间的距离。d3.hexbin()
将为我们精确计算这个距离,但现在我们想要一个好的近似值。所以,如果我们决定有 160 列的点,我们的宽度是 840,最大距离将是840 / 160 = 5.25像素。然后我们计算行数。高度是 540,因此我们可以容纳540 / 5.25行,向下取整等于 108 行点:
function getPointGrid(cols) {
var hexDistance = width / cols;
var rows = Math.floor(height / hexDistance);
hexRadius = hexDistance/1.5;
接下来,我们将计算hexRadius
。这看起来可能有点奇怪。为什么要把距离除以1.5?如果我们将点和期望的六边形半径输入 D3-hexbin 模块,它将为我们生成六边形。我们在这里设置的六边形半径应该保证生成的六边形足够大,至少包含我们生成的网格中的一个点。毕竟,我们想要一个无缝的六边形铺砖。所以,一个紧密的网格应该有一个小的半径,而一个宽的网格应该有一个更宽的半径。如果我们有一个宽的网格和小的半径,我们就不会为每个点得到一个六边形。会有缝隙。
幸运的是,六边形是规则的形状,它们的尺寸和属性是相互关联的。六边形中心之间的垂直距离是其半径的 1.5 倍,水平距离是√3(大约 1.73):
六边形距离和半径之间的关系
我们的网络点作为六边形中心的代理。因此,它们在布局上并不“完美”,因为它们的垂直距离与水平距离相同,都是5.25像素。在一个完美的六边形网格中,垂直距离会比水平距离略短,如图所示。为了在我们的代理网格的基础上获得相对紧密的六边形网格,我们应该选择一个安全的——即宽的——半径传递给 D3-hexbin 模块,它确实会提供一个完美的六边形网格。我们可以通过前图中的公式以及我们的距离(5.25 像素)通过求解半径来计算这个半径。当重新排列垂直距离的方程时,距离 = 1.5 * 半径变为半径 = 距离 / 1.5。在我们的情况下,距离是5.25 / 1.5 = 半径为3.5。使用水平距离将给我们一个更安全的——即更紧的——半径,5.25 / √3 = 3.03,这实际上会在我们的最终铺砖中产生一些缝隙。
接下来,我们将立即创建并返回网格——好吧,网格的坐标:
return d3.range(rows * cols).map(function(el, i) {
return {
x: Math.floor(i % cols * hexDistance),
y: Math.floor(i / cols) * hexDistance,
datapoint: 0
}
});
} // end of getPointGrid() function
d3.range(rows * columns)
创建一个数组,每个点对应一个元素。然后我们使用.map()
遍历每个点,返回一个具有三个属性的对象:x
、y
和datapoint
。这些属性将定义我们的每个网格点。x坐标在每个点增加hexDistance
,并在每一行重置为0(或者换句话说,在它运行完所有列之后)。y坐标在每一行增加hexDistance
。
同样重要的是,每个网格点都将获得一个名为datapoints
的属性,我们将将其设置为0。这个属性将区分所有布局点(0)和数据点(1),这样我们就可以专注于后者。
恭喜!这是最困难的部分,而你仍然自豪地举起一个方形的番茄色点阵。
注意,虽然不是关键,但将我们在路上标记的网格和点可视化是非常有帮助的。这里有一个小函数,如果点存储在具有x
和y
属性的数组对象中,它会绘制这些点:
function drawPointGrid(data) {
svg.append('g').attr('id', 'circles')
.selectAll('.dot').data(data)
.enter().append('circle')
.attr('cx', function(d) { return d.x; })
.attr('cy', function(d) { return d.y; })
.attr('r', 1)
.attr('fill', 'tomato');
}
仅保留地图内的点
这些点的正方形网格与美国地图的形状仍然相距甚远。让我们改变一下。多亏了 D3 的d3.polygonContains()
方法,这相当简单。该方法接受多边形的屏幕坐标和一个点,对于每个点,如果点在多边形内则返回true
,否则返回false
。这非常有帮助。
为了获取我们的美国地图的多边形,我们编写了一个名为getPolygonPoints()
的小函数,并将其用作ready()
函数的下一步,到目前为止,这个函数看起来是这样的:
function ready(error, us) {
var us = prepData(us);
drawGeo(us);
var points = getPointGrid(160);
var polygonPoints = getPolygonPoints(us); }
我们传递的唯一参数是我们地图的 GeoJSON 对象数组,称为us
。出于简单起见,我们决定只查看美国大陆。因此,我们首先需要将数据集中在美国大陆上:
function getPolygonPoints(data) {
var features = data.features[0].geometry.coordinates[7][0];
var polygonPoints = []
features.forEach(function(el) {
polygonPoints.push(projection(el));
});
return polygonPoints;
}
data.features[0].geometry.coordinates
包含 11 个多边形点对数组,描述了美国大陆、阿拉斯加、夏威夷以及更远的海域。我们想专注于美国大陆,其轮廓由第七数组中的第一个元素表示。请注意,如果您的数据来自不同的来源或以不同的方式组装,这可能会不同。
然后,我们将遍历所有polygonPoints
,它们位于经纬度上,并将它们转换为x和y坐标以供进一步使用。
现在,我们既有美国地图的多边形边界,也有我们的网格点在像素坐标中。我们现在需要做的就是识别位于美国大陆内的网格点:
var usPoints = keepPointsInPolygon(points, polygonPoints);
我们将这两个数组传递给一个我们大胆命名的keepPointsInPolygon()
函数:
function keepPointsInPolygon(points, polygon) {
var pointsInPolygon = [];
points.forEach(function(el) {
var inPolygon = d3.polygonContains(polygon, [el.x, el.y]);
if (inPolygon) pointsInPolygon.push(el);
});
return pointsInPolygon;
}
在这里,我们创建了一个名为pointsInPolygon
的空数组,它将存储我们的美国专属点。然后我们遍历我们的网格点,并检查每个点是否位于美国多边形内。如果是,我们就将其传递到pointsInPolygon.
中。
如果我们绘制这些点,我们会看到一个尖锐的美国地图:
美国在点上的表示
您可以在浏览器中查看此步骤:larsvers.github.io/learning-d3-mapping-11-3
,以及代码示例:11_03.html。
制作六边形瓦片
点看起来很漂亮,但我们是来画六边形的。所以,让我们最终绘制它们并介绍 D3-hexbin 插件。
它需要我们从以下两个方面获取信息,并返回两个更有价值的东西:
-
我们需要提供一组在屏幕坐标中的点以及我们希望看到的半径。
-
它返回一个六边形中心点的网格(每个六边形一个)和一个六边形路径生成器。
然后,我们使用新的中心点和路径生成器,按照 D3 的惯例,使用我们选择的渲染器自己绘制它。首先,让我们获取六边形的中心点,然后用 SVG 绘制它们。在我们的 ready()
函数中,我们将添加以下两行:
function ready(error, us) {
//previous steps
var hexPoints = getHexPoints(usPoints);
drawHexmap(hexPoints);
}
getHexPoints()
获取中心点,drawHexmap()
绘制它们。
获取六边形中心点
如前所述,d3.hexbin()
有两个头。它的第一个用途是作为 D3 布局函数,例如 D3 提供的力布局、树布局或圆包布局函数。输入数据,输出增强数据。我们传递我们的数据和所需的六边形半径给它,对于它可以围绕其形状包裹的每一组数据点,它将返回该六边形的中心坐标。
如果我们只给它一个数据点,它将返回一个六边形。如果我们给出两个靠近的数据点,这样它们可以适应由半径定义的六边形的宽度和高度,它也将只返回一个六边形。如果第二个数据点离第一个数据点很远,以至于六边形无法用给定的半径覆盖它,d3.hexbin()
将产生第二个六边形,包围那个第二个点。
在这里,我们使用它的布局能力:
function getHexPoints(points) {
hexbin = d3.hexbin() // note: global
.radius(hexRadius)
.x(function(d) { return d.x; })
.y(function(d) { return d.y; });
var hexPoints = hexbin(points);
return hexPoints;
}
首先,我们配置布局。我们将半径 3.5(5.25 的距离除以 1.5)添加到其中,并引导它的注意力到它可以找到 x
和 y
坐标的地方。在下一行,我们在我们的 points
网格上使用它,并返回如下所示的结果数组:
我们通过 d3.hexbin() 返回的 hexPoints
我们的网络点由 5,996 个六边形中心点表示,我们将其简称为hex points。让我们简要地了解一下。hexbin 布局返回一个数组。每个元素代表一个单独的六边形。在每个元素中,每个对象代表六边形覆盖的点。此外,d3.hexbin()
向数组添加两个键:x
和 y
。它们的值代表六边形的中心。因此,对于每个六边形,我们都有所有点数据以及六边形的中心坐标。
如您在前面的截图中所见,前两个六边形仅覆盖一个网格点,而第三个覆盖两个网格点。您还可以看到数组键中的中心点与对象中的布局点略有不同。让我们可视化一下。
绘制六边形瓦片
我们已经有了六边形,现在只需要绘制它们。我们在ready()
函数中通过一个新的函数drawHexmap(hexPoints)
来完成这个任务。它就是它所说的那样:
function drawHexmap(points) {
var hexes = svg.append('g').attr('id', 'hexes')
.selectAll('.hex').data(points)
.enter().append('path')
.attr('class', 'hex')
.attr('transform', function(d) {
return 'translate(' + d.x + ', ' + d.y + ')'; })
.attr('d', hexbin.hexagon())
.style('fill', '#fff')
.style('stroke', '#ccc')
.style('stroke-width', 1);
}
我们将数据(以points
的形式传入)与我们的虚拟选择.hex
六边形连接,并使用d.x
和d.y
移动到每个六边形的中心。在每个中心,我们解开我们六边形实例的第二次使用:六边形路径生成器。hexbin.hexagon()
将返回路径的d
属性所需的字符串来绘制形状。六边形的尺寸将基于我们在配置期间传递给它的半径。其余的都是基本的样式。
hexbin.hexagon()
也可以接受一个半径作为参数。使用访问器函数,我们甚至可以通过一个六边形点特定的参数传递,这意味着我们可以根据数据值改变每个六边形的大小。太棒了!然而,我们现在没有时间或数据来做这件事,所以让我们稍后再说。
好吧,那么。这是你的六边形瓦片;你应得的:
六边形地图
在浏览器中查看此步骤:larsvers.github.io/learning-d3-mapping-11-4
,以及代码示例:11_04.html。
将数据点与布局点连接
到目前为止,我们只关注了基础层设置,将布局点可视化成六边形。现在,我们终于要添加一些真实数据了。首先,我们需要将其加载到我们的d3.queue()
中:
d3.queue()
.defer(d3.json, 'data/us.json')
.defer(d3.json, 'data/markets_overall.json')
.await(ready);
在ready()
函数中,我们只是向我们的可视化管道添加另一行,触发一个将为我们准备数据的函数:
function ready(error, us) {
// … previous steps
var dataPoints = getDatapoints(markets)
}
getDatapoints()
简单地接受加载的 CSV 数据,并返回一个更简洁的对象,具有x和y屏幕坐标以及datapoint
标志,表示这不是布局点,而是一个实际的数据点。其余的是市场特定的数据,如name
、state
、city
和url
,我们可以用来为每个六边形添加信息:
function getDatapoints(data) {
return data.map(function(el) {
var coords = projection([+el.lng, +el.lat]);
return {
x: coords[0],
y: coords[1],
datapoint: 1,
name: el.MarketName,
state: el.State,
city: el.city,
url: el.Website
}
});
}
回到ready()
函数中,您只需将这些数据点连接到布局点,以获得您将用于最终六边形地图的完整数据集:
function ready(error, us) {
// … previous steps
var dataPoints = getDatapoints(markets)
var mergedPoints = usPoints.concat(dataPoints)
}
这里是市场数据,以蓝色表示的经典点密度图可视化,以及与红色表示的网格布局数据一起:
左图显示了农民市场点;右图显示了用蓝色表示的农民市场点和用红色表示的布局点。
在浏览器中查看此步骤,请访问 larsvers.github.io/learning-d3-mapping-11-5
以及代码示例 11_05.html。
太好了!我们离我们的六边形图又近了一步。我们需要创建一个可以可视化的值:市场的数量。
为最终表演打扮我们的数据
你有一些关于农民市场的真实数据,这些数据与六边形相关联,但你现在还不能使用它。所有你的数据仍然被存储在每个六边形对象的数组中。让我们将这些数据卷曲起来。
我们想要可视化的度量是每个六边形区域中农民市场的数量。因此,我们只需要计算具有 datapoint
值设置为 1 的对象数量。在此过程中,我们还将移除布局点对象,即 datapoint
值为 0 的对象;我们不再需要它们。
我们将把我们的任务添加到 ready()
函数中:
function ready(error, us) {
// … previous steps
var hexPointsRolledup = rollupHexPoints(hexPoints);
}
主要的 rollupHexPoints()
函数将卷曲每个六边形点的市场数量。它将上图的六边形数据转换为以下图的下方六边形数据:
卷曲前后的六边形数据
rollupHexPoints()
将按以下顺序执行以下操作:
-
移除布局网格点。
-
计算数据点的数量,并将计数作为一个新的属性
datapoints
添加。 -
收集关键市场数据到一个名为
markets
的单一数组中,以便于轻松访问交互。 -
最后,它将产生我们迫切需要的用于六边形着色的颜色刻度。
我们开始:
function rollupHexPoints(data) {
var maxCount = 0;
我们首先初始化一个 maxCount
变量,它将后来包含单个六边形中农民市场的最大数量。我们需要这个变量来设置颜色刻度。
接下来,我们将遍历所有的布局和数据点:
data.forEach(function(el) {
for (var i = el.length - 1; i >= 0; --i) {
if (el[i].datapoint === 0) {
el.splice(i, 1);
}
}
首先,我们将移除所有 datapoint
属性值为 0
的布局点对象。
接下来,我们将创建卷曲的数据。将有两个卷曲数据元素:一个表示六边形内农民市场的总数,以及一个我们可以用于后续交互的市场数据数组。首先,我们将设置变量:
var count = 0,
markets = [];
el.forEach(function(elt) {
count++;
var obj = {};
obj.name = elt.name;
obj.state = elt.state;
obj.city = elt.city;
obj.url = elt.url;
markets.push(obj);
});
el.datapoints = count;
el.markets = markets;
我们遍历六边形对象数组中的每个对象,一旦收集了数据,我们就将其作为键添加到数组中。现在这些数据与六边形点的 x 和 y 坐标处于同一级别。
注意,我们可以通过简化市场计数来取捷径。我们的 datapoints
属性只是计算数组中的元素数量。这与内置的 Array.length
属性所做的是完全相同的。然而,这是一个更自觉且更具描述性的方法,而且不会增加太多的复杂性。
在循环的最后一件事是更新 maxCount
,如果特定六边形的计数值高于我们之前遍历的所有六边形的 maxCount
值:
maxCount = Math.max(maxCount, count);
}); // end of loop through hexagons
colorScale = d3.scaleSequential(d3.interpolateViridis)
.domain([maxCount, 1]);
return data;
} // end of rollupHexPoints()
在我们的roll-up
函数中,我们最后要做的事情是创建我们的colorScale
。我们使用的是Viridis颜色尺度,它具有很好的可视化计数数据的特性。请注意,Viridis将低数值映射到紫色,高数值映射到黄色。然而,我们希望高数值更暗(更多紫色),低数值更亮(更多黄色)。我们将通过翻转我们的域映射来实现这一点。
尺度内部工作的方式是,我们从域中提供的每个值都将被归一化到0到1之间的值。我们传递给.domain()
数组的第一个数字将被归一化到0——在我们的例子中是maxCount
或 169。第二个数字(1)将被归一化到1。输出范围也将映射到0到1的范围,对于Viridis来说意味着0 = 紫色和1 = 黄色。当我们向我们的尺度发送一个值时,它将归一化该值并返回0到1之间的相应范围值。以下是将数字 24 输入时的结果:
-
尺度接收24作为输入(如
colorScale(24)
)。 -
根据
.domain()
输入([max, min]
而不是[min, max]
),尺度将24归一化到0.84。 -
接下来,尺度查询Viridis插值器,询问哪种颜色对应于Viridis颜色尺度上的0.84值。插值器返回颜色
#a2da37
,这是一种浅绿色。这很有道理,因为 0.84 更接近 1,代表黄色。浅绿色显然比深紫色更接近黄色,而插值器将其编码为0。
那就是全部了!
几乎是。我们最后要做的就是跳入我们的drawHexmap()
函数,并将六边形的着色更改为我们的colorScale:
。
function drawHexmap(points) {
var hexes = svg.append('g').attr('id', 'hexes')
.selectAll('.hex').data(points)
.enter().append('path')
.attr('class', 'hex')
.attr('transform', function(d) {
return 'translate(' + d.x + ', ' + d.y +')';
})
.attr('d', hexbin.hexagon())
.style('fill', function(d) {
return d.datapoints === 0 ? 'none' : colorScale(d.datapoints);
}) .style('stroke', '#ccc')
.style('stroke-width', 1);
}
如果六边形不覆盖任何市场,其数据点的属性将为 0,我们不会对其进行着色。否则,我们选择适当的 Viridis 颜色。
这里就是它:
一个非常黄色的六边形图
看起来很黄,不是吗?问题是我们的数据中有一两个异常值。东海岸的那个单独的深紫色点就是纽约,它的农民市场数量比任何其他地区都多得多(169)。华盛顿和波士顿也很繁忙。然而,这使得我们的视觉效果不那么有趣。观察数字的分布告诉我们,大多数六边形包含 20 个或更少的农民市场:
每个六边形内的农民市场数量
然而,每个六边形内的市场数量最高目前是 169。我们可以做两件事。我们可以选择一个较低的值作为我们的最大颜色尺度值,比如说 20。这样,我们的值将从 1 缩放到 20 到Viridis光谱。所有具有更高值的六边形将默认接收最大颜色(紫色)。
一个更优雅的替代方案是使用 指数插值器 作为颜色刻度。我们的域将不是线性映射,而是指数映射到我们的颜色输出,有效地使用更低的值达到颜色光谱的末端(紫色)。为了实现这一点,我们只需要一个新的具有自定义插值器的颜色刻度。让我们首先看看代码:
colorScale = d3.scaleSequential(function(t) {
var tNew = Math.pow(t,10);
return d3.interpolateViridis(tNew)
}).domain([maxCount, 1]);
我们在这里究竟做了什么?让我们重新考虑在前面代码中经历的缩放步骤:
-
刻度接收一个数字 24(如在
colorScale(24)
中)。 -
根据
.domain()
输入([max, min]
而不是[min, max]
),刻度将 24 归一化为 0.84。对于点 1 和 2 没有变化。 -
在旧的
colorScale
中,我们只是简单地通过这个在 1 和 0 之间的 线性归一化值,没有我们的干预。现在,我们将其作为一个回调函数的参数。惯例让我们称这个为t
。现在,我们可以按需使用和转换它。正如我们之前看到的,许多六边形围绕着 1 到 20 个市场,围绕更多的很少。因此,我们希望在值域的较低范围内遍历 Viridis 颜色空间的大部分,以便颜色刻度编码我们数据的有兴趣的部分。我们如何做到这一点? -
在我们将
t
传递给我们的颜色插值器之前,我们将其设置为 10 的幂。我们可以使用不同的指数,但 10 就足够了。一般来说,取一个介于 0 和 1 之间的数字的幂会得到一个更小的数字。幂越高,输出就越小。我们的线性t
是 0.84;我们的指数tNew
等于 0.23。 -
最后,我们将
tNew
传递给 Viridis 插值器,它输出相应的——更暗的——颜色。
让我们绘制这个转换以澄清:
线性与指数颜色插值
x 轴 显示输入值,y 轴 显示我们发送到插值器以检索相应颜色的刻度归一化值 t
。左图 显示线性插值做了什么。它将值的增加线性地转换为 t
的减少。右图 中的曲线显示我们调整后的 tNew
在将 t
设置为 10 的幂 后的行为:我们使用更小的输入值进入 t
的较低区域(更紫的区域)。换句话说,我们在一个更小的域值范围内遍历从黄色到紫色的颜色空间。将我们的示例值 24 通过 线性插值 会返回一个黄绿色的值;通过我们的 指数插值 已经返回了颜色光谱末端的紫色值。
这个主要的优势是,颜色差异可以在数据所在的位置而不是在主要数据簇和异常值之间的差距处看到。以下是我们的指数刻度六边形图:
一个更有趣颜色的六边形图
在浏览器中查看此步骤:larsvers.github.io/learning-d3-mapping-11-6
,以及代码示例:11_06.html。
让我们暂时陶醉于我们的成就,但我们已经完成了吗?我们迫不及待地想更深入地探索这个地图。毕竟,人们习惯于玩地图,试图在其中定位自己或轻松地从一处移动到另一处。这就是我们在最后一步将允许的事情。
将我们的视觉应用转变为一个交互式应用
您已正式构建了一个六边形地图,这确实是本章的关键焦点。然而,在本节的最后,让我们悠闲地考虑如何使这个应用对我们和我们的用户来说更加吸引人和信息丰富。我们不会像前几节那样详细,但会概述如何改进应用的一般步骤。
这里有一份我们可以做的事情列表:
-
在悬停时以列表形式显示市场。
-
允许用户更改六边形的尺寸。
-
允许用户更改颜色插值器的指数。
-
显示哪些市场销售特定产品,例如奶酪、葡萄酒、海鲜等。
-
将第二个变量编码为六边形大小。
第一点是标准的。第二和第三点对于数据探索非常有帮助。第四点是可能的,因为数据还涵盖了指定每个市场销售什么产品的变量。最后一点将有助于我们使用d3.hexbin()
模块进行实践。
我们不会详细说明这些点的每个细节,但请查看完成的larsvers.github.io/learning-d3-mapping-11-8
应用。代码有注释,并在Chapter 11
文件夹中的示例11_08.html中提供,位于github.com/larsvers/Learning-D3.js-4-Mapping
上。
在悬停和点击时添加额外信息
工具提示是大多数可视化中的一种有用的探索技术,以便向用户提供有关特定数据点或区域的信息。在这种情况下,应用的最小数据单元是六边形。然而,一些六边形包含更多适合工具提示的信息——多达 169 个,如我们上面所见。为了允许用户按区域浏览市场,我们将添加一个侧面板,列出所有悬停六边形中的市场。这将是它的样子:
带有标题、工具提示和每个六边形市场列表的交互式六边形地图
在浏览器中查看此步骤的larsvers.github.io/learning-d3-mapping-11-8
。代码示例在11_08.html。请使用较新的 Chrome 浏览器查看或工作在这些示例中。
当用户移动时,列表会迅速变化,因此点击六边形会锁定列表视图,以便用户可以探索并可能使用链接访问市场的网站。
改变六边形的大小
与点密度地图相比,六边形地图的一个关键优势是交互更容易。如果你在地图上有许多点,你可能必须保持它们很小(1-3 像素)以传达良好的数据感。这样的小目标很难用鼠标捕捉。此外,一些点不可避免地会重叠,因此你根本无法到达它们。在六边形地图中,每个六边形都是可到达的,如果它不是太小。我甚至可以说,我们选择的六边形大小可能有点小,3.5像素。让我们添加一个下拉控制,允许用户更改区域的大小。以下是一些六边形大小变体:
不同的六边形半径
这里有两个注意事项:当我们构建像我们这样的规则六边形镶嵌地图时,可能会遇到边界问题。想象一个六边形刚好接触佛罗里达州的尖端。这个六边形的 5%在陆地上,95%在海上。然而,地图读者看不到真正的海岸线。他们假设六边形代表海岸线,覆盖 100%的陆地面积。如果佛罗里达州这个角落有高密度点,这个六边形应该编码它。然而,因为它只覆盖了 5%的陆地,也许大约 5%的点密度,它对读者来说就像佛罗里达州的海岸线没有点一样。
当你观察上面不同大小的六边形时,会出现另一个明显的问题,即所谓的可修改面积单位问题(MAUD)。我们编码的汇总值高度依赖于我们聚合单元的形状和规模。这是一个问题,因为当相同的分析应用于相同的数据时,结果可能会有所不同。你可以在上面看到尺度效应;改变六边形的大小会导致对农贸市场密度的不同感知。形状或区域效应可能更加成问题。例如,使用与相同规模不同的形状,比如 10 英里正方形而不是 10 英里六边形,可以改变总体和因此分析。这样,分析就变成了对不同箱子的分析,而不是对基础数据的分析。这种效应在表示非任意单位(如县或人口普查区)时尤其成问题,这些单位可能会随时间改变形状,但在读者的心目中保持一致。
您可以通过叠加国家轮廓来解决边界问题,但缓解任何这些问题的关键步骤是您的意识,向读者解释潜在问题。
改变颜色比例插值器
在探索性显示中,用户改变比例以发现感兴趣的数据区域可能是有益的。通过允许我们的用户调整插值器,他们可以关注他们感兴趣的价值范围。我们想要公开的参数是我们指数插值器使用的指数:
改变颜色比例插值器
浏览不同的数据集
我们在这里使用的农民市场数据也提供了超过 29 个二元变量,表明市场提供的产品或设施。展示不同产品地理分布的多个数据集是一个好选择。或者,我们可以为用户添加下拉菜单,让他们选择他们最感兴趣的产品。以下是一些示例:
不同数据集的六边形图(我们选择了 29 个二元变量中的 20 个)
将数据编码为六边形大小
到目前为止,我们只将数据编码为颜色。d3.hexbin()
使得通过六边形大小编码数据变得非常简单。理论上,你只需进入你的drawHexmap()
函数并更改一行:
.attr('d', function(d) { return hexbin.hexagon(d.datapoints); })
您只需将六边形特定的半径添加到您的hexbin
路径生成器中(作为.hexagon()
方法的可选参数),在我们的例子中,这确保每个六边形都得到一个与该六边形农民市场的数量一样小或大的半径。然而,这看起来会过于夸张,因为大多数将得到半径为0,而一些将得到超过100的半径。我将省略视觉效果。
相反,我们将添加变量radiusScale
到混合中(在rollUpHexPoints()
中),这将使大小在3.5
到15
像素之间缩放:
radiusScale = d3.scaleSqrt().domain([0, maxCount]).range([3.5, 15]);
现在当您绘制六边形时,您也应该按升序排序,以便较大的六边形不会被周围的许多小六边形覆盖:
function drawHexmap(points) {
var hexes = svg.append('g').attr('id', 'hexes')
.selectAll('.hex')
.data(points.sort(function(a,b) {
return a.datapoints - b.datapoints;
})) .enter().append('path')
.attr('class', 'hex')
.attr('transform', function(d) {
return 'translate(' + d.x + ', ' + d.y + ')';
})
.attr('d', function(d) {
return hexbin.hexagon(radiusScale(d.datapoints));
}) .style('fill', function(d) { return
d.datapoints === 0 ? 'none' : colorScale(d.datapoints);
})
.style('stroke', '#ccc')
.style('stroke-width', 1);
}
您得到的六边形不仅着色,而且根据六边形内的市场数量进行大小编码:
市场数量编码为颜色和半径大小
在浏览器中查看此步骤的链接为larsvers.github.io/learning-d3-mapping-11-7
,代码示例在11_07.html。
我们在这里通过颜色和大小双重编码市场数量。这有时很有用,但您现在有两个编码通道可供使用,您可以使用两个变量来生成双变量六边形图。这是您的选择。
我们介绍了几种改进和添加到我们的六边形地图的选项。当然,还有更多选项可以让我们玩得开心。例如,我们还没有涉及到缩放和平移,这当然是一种标准的地图交互技术,对于想要深入研究更小六边形的人来说,这将是一个很好的补充。我相信你还能想到更多构建它的方法。
摘要
我们首先比较了几种地图可视化技术。我们介绍了等值线图、地图图和点密度图的使用、优点和注意事项。我们迅速转向六边形形状,并探讨了其几何属性如何帮助等值线图和点密度图。
然而,大部分时间都花在了制图工作坊中,从头开始构建一个六边形地图。我们本可以构建一个简单的六边形地图,只需覆盖有数据点的区域,但我们的目标是打造一个完全由六边形构成的地图,以追求乐趣和美观。这意味着需要更多的数据准备——创建地图形状的布局网格,连接数据点,最终添加并使用颜色编码六边形——但难道它看起来不漂亮吗?
最后,我们将静态地图转换成了一个交互式应用程序,将形状和信息获取的控制权交给了用户。交互可以实现很多事情,尤其是对于地图来说!
在创建了一个完全功能性的交互式可视化之后,你可能想要向世界展示它。有许多方式可以在线占据一些空间;在下一章中,我们将探讨一个方便简单的方法:GitHub 页面。
第十二章:使用 GitHub Pages 发布您的可视化
在完成创建您的可视化的艰苦工作后,您应该向世界展示它,而互联网似乎是展示它的完美场所。
在本章中,您将学习如何在线发布您的可视化。有几种方法可以实现,从使用您的机器作为服务器执行简单的命令行单行命令,到需要您自己构建和部署服务器的完整托管服务。我们将重点介绍一种简单、快速且方便的方法——GitHub Pages。以下是我们将涵盖的内容:
-
理解 Git、GitHub 和 GitHub Pages
-
创建一个包含您的文件和文件夹的 GitHub 仓库
-
将您的文件上传到 GitHub 并编辑它们以供发布
-
在 GitHub Pages 上发布您的可视化
这些步骤都很简单,无论您是经验丰富的 Git 和 GitHub 用户,还是刚刚开始,都是如此。让我们深入探讨。
我们将要发布的内容
首先,您需要一些可以发布的内容。假设在映射了这么多地球事物之后,您正在向太空进发——映射我们的太阳系:
粉红色的行星
这个可视化显示了我们的太阳系中所有的行星,按从左到右的顺序排列,按距离太阳的远近排列,并显示它们相对于彼此的缩放大小。它还显示了太阳(不是一个行星,而是一颗恒星)和冥王星(官方上,是一颗矮行星)。
因此,在这个阶段,您在 HTML 文件或一组拼接到 HTML 或 JavaScript 文件中的文件中有一个可视化;这就是您所需要的。由于 GitHub Pages 使过程变得如此简单,您的发布作品只需几步即可完成。
欢迎查看我们将设置的 GitHub 账户,包括代码在 github.com/GoodToBeHere/pink-planets
以及发布的视觉作品在 goodtobehere.github.io/pink-planets/
或 planetsin.pink/。
理解您可以发布的内容类型
在我们深入之前,让我们简要地考虑一下 GitHub Pages 可以托管的内容类型。简而言之,GitHub Pages 将允许您托管任何 静态网页 或具有 动态客户端脚本 的网页。如果您想托管具有动态服务器端脚本的项目,您可能需要选择不同的设置,例如 Heroku。
简而言之,静态或平面网页由一组文件组成——或者至少是一个为每个用户提供相同内容的单个 HTML 文件。如果您想更改内容,您需要更改源代码。
动态网页由一组等待外部请求或输入的文件组成,然后即时构建、更改并服务内容。它们可能为不同的用户显示不同的内容,或者在不同时间或操作系统上显示。
动态网页有两种类型:
-
使用 客户端脚本 的网页通常使用 JavaScript 及其衍生品将用户输入转换为更改后的 DOM 结构。它是 客户端 执行的,这意味着它完全在浏览器的范围内发生。
-
使用 服务器端脚本 的网页需要将输入参数传递到应用程序的服务器。PHP、Python、Node.js 或类似的服务器端语言以特定情况的方式组装页面内容,通常是从数据库中检索信息。
简而言之,如果您的项目不依赖于服务器端脚本,您可以在 GitHub Pages 上托管它。只要用户输入在客户端范围内发生,您就可以促进用户输入。您可以在 GitHub 页面上加入和更新所有来自存储为平文件的数据,这可以是 .csv
或其他分隔数据文件,但您不能连接到,例如,一个关系型 SQL 数据库(它依赖于结构化的相互关系)。当然,您可以在客户端计算和重新计算数据。
为了选择合适的托管技术来发布您的可视化,了解这一限制非常重要。然而,野外许多 D3 项目可以作为静态网页或具有专用客户端脚本的动态网页托管。
在 GitHub 上托管您的代码
在本节中,我们将为您设置视觉内容的发布。我们将解释一些关键术语,这将有助于理解 GitHub 及其目的;我们创建账户,上传我们的内容,并为其发布做准备。这将是对 GitHub 的温和介绍。如果您已经有了账户并且了解仓库、提交、拉取、推送、合并和分支,请自由跳转到 在 GitHub Pages 上发布您的项目 部分。
理解一些关键术语和概念
让我们先看看一些术语。如果您是经验丰富的 Git 或 GitHub 用户,请自由跳过这个介绍。
这是对 Git 和 GitHub 的非常高级的介绍,绝对不是完整的。如果您想了解更多关于 Git 的信息,我建议阅读优秀的文档 git-scm.com/
。如果您想了解更多关于 GitHub 的信息,guides.github.com/activities/hello-world/
是一个很好的起点。
Git 是一个 分布式版本控制系统。让我们来分解一下。文件的 版本控制(通常是一个文本文件,如 .html
或 .js
文件)仅仅意味着跟踪文件的所有更改。在使用它之前,您需要下载、安装并在您想要跟踪文件的文件夹中初始化它。Git 有三个核心概念,可以让您的编码生活变得更轻松。
跟踪您文件的历次更改
一旦在一个文件夹中初始化,Git 允许你在更改文件后将其保存或提交到该文件夹。如果没有 Git,你可能不得不降低到显式复制你正在工作的文件,并滥用其名称来表示版本,例如 myFile-v01.html
和 myFile-v02.html
。相反,Git 会为每个提交分配一个提交 ID。回顾所有提交很容易;只需打开或检出你想要看到的版本即可。通常,你不仅仅控制单个文件,而是控制位于不同项目文件夹中的多个文件。所有你想要进行版本控制的键文件的主文件夹,你在其中初始化 Git 的文件夹,被称为 Git 仓库。
在项目上进行协作
与其前辈相比,Git 的一大优势在于它是分布式的。它允许许多人访问同一项目文件,并且每个获得授权的人都能看到每个文件的完整版本历史。它是为了协作而设计的。为了共享仓库,它需要通过某种网络方式可访问,例如,互联网。这就是 GitHub 发挥作用的地方。
假设你以一个明确的目标开始一个项目,即可视化我们的太阳系,你可以自己开始编码。你创建自己的本地仓库,并将你想要在仓库中跟踪的文件添加和提交。几天后,经过多次提交,你意识到这个任务对你一个人来说太大,难以完成。你邀请朋友来帮忙。为了共同工作在代码库上,你将所有文件上传到 GitHub,并通过 Git 将其与本地仓库关联起来。Git 现在知道有一个本地仓库和远程仓库,后者镜像了本地仓库。
一旦你在本地对文件进行了更改并提交了它们,你可以通过推送这个文件来覆盖远程 GitHub 仓库中的相同文件。你的朋友可以将你做出的更改拉到他们的本地机器上,看到与你完全相同的文件,包括你刚刚更改的文件以及之前所做的所有更改。
在项目分支上工作
你还可以维护你项目的并行分支。每个分支都是你项目的副本。
想象一下,您有一个生产版本的项目,它存在于每天被数百万访客访问的网站上。这个生产版本将是您的主分支。现在您想要添加一个新功能;例如,一个将行星颜色从粉红色变为紫色的按钮。您不希望在生产版本中开发这个功能。这可能会很危险,因为事情可能会出错,您的数百万访客可能会因为厌恶而离开。所以,您创建了一个项目的副本——一个您可以称之为 purple 的分支。现在您可以在本地愉快地开发,提交不同的版本,并做出和修复错误。您可以将您的更改推送到 GitHub,与您的朋友协作,直到它工作,经过测试,让您满意。然后,您将所有更改从 purple 分支复制到 master 分支,或者用 Git 术语更好地表达:您合并了 purple 分支到 master 分支。
正如所说,这只能是对 Git 和 GitHub 的基本功能的快速浏览。当然还有更多,但只要您对这些关键概念和术语感到舒适,您现在就可以自信地为自己和项目在 GitHub 上设置账户。
设置 GitHub 账户
由于设置在线账户可能是您在生活中已经做过很多次的事情,我们不会在这方面花费太多时间。下面是步骤:
设置 GitHub 账户
前往 github.com/
,点击 Sign in,并按照步骤操作。我们通过选择用户名 GoodToBeHere
并输入我们的电子邮件地址和密码来表达我们对 GitHub 的一般积极态度。在第二屏,我们选择免费账户,并在第三屏回答几个可选问题后,完成注册。
创建仓库
接下来,我们想要设置一个仓库。请记住,仓库是您项目的根目录,包含所有您的文件和文件夹。在 GitHub 上创建这个仓库有两种方式:
-
您可以将本地仓库推送上去——即您本地机器上的项目文件夹
-
您可以通过 GitHub 在线创建一个新的仓库
在本章中,我们只将探讨第二种方式,并在网上完成所有操作。
如果您持续使用 Git 和 GitHub 进行工作,通过命令行将项目连接到 GitHub 是很有意义的。这只需要对命令行有基本的熟悉度,您可以在help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
了解更多信息。
在设置好账户后,您将被引导到您自己的 GitHub 仪表板,正如您在 URL 中看到的:
您的 GitHub 仪表板
要创建一个新的仓库,请点击“New repository”。
考虑到下一张截图,我们首先命名我们的仓库。您所有的仓库都将归您所有,因此它们都将以您的用户名开头。每个仓库在您的仓库列表中都需要有一个独特的名称。目前,您非常灵活,因为这是您迄今为止的第一个也是唯一的仓库。我们将适当地将我们的仓库命名为pink-planets
,避免在名称中使用空格:
创建仓库
我们所有的仓库都将公开,这意味着每个人都可以看到您的所有代码!如果您希望或需要保持您的代码私密,您可以选择付费的个人计划。
初始化仓库时包含一个README
文件是一个好习惯,它允许您描述您的项目。不用担心.gitignore
文件,我们在这里不需要它。最后,我们添加一个许可证,在我们的例子中是一个 Apache License 2.0。您可以选择的许可证有很多,了解这里的选项是个好主意。
最后,我们点击那个大绿色的“创建仓库”按钮,它确实会这样做,并直接带我们到仓库页面:
我们仓库的页面
您可以看到您仓库的名称,一个带有多个标签的控制栏,显示当前聚焦的代码标签的内容,以及一个快速描述。接下来,有一个显示一些一般信息的栏,例如已经完成的提交(或文件保存)数量,这个仓库有多少分支(或项目副本),有多少个发布,以及有多少贡献者可以访问这个仓库。
在按钮控制行之后,我们有一个我们仓库中所有文件和文件夹的文件夹视图。目前,只有我们刚刚创建的两个文件:LICENSE
和README.md
文件;.md
代表markdown,它允许您使用简单的语法格式化文本文件。您可以在几分钟内了解其精髓,例如在guides.github.com/features/mastering-markdown/
。
在底部,您可以看到README
文件的内容,为访问者或合作者提供了一个关于您的项目快速总结。
它仍然有点单调,所以让我们来修改它,同时学习如何通过 GitHub 编辑文件。
在 GitHub 上编辑文件
如果您想编辑一个文件,首先需要点击您仓库中它的链接。接下来,您点击右上角的“编辑此文件”铅笔:
打开文件进行编辑
这将带您进入文件的可编辑视图。在这里,您可以在编辑器窗口上方更改文件的名称(我们保留它,因为我们喜欢README.md
这个名字),您可以在“编辑文件”视图中更改文件的内容:
修改 README.md 文件
我们在这里只做了一些更改:我们将标题大写,并添加了一些文字来描述项目的内容。我们使用 markdown 语法通过在文本前加 #
来标记标题为一级标题。我们还使用 markdown 将 sun
以粗体形式打印出来,使用前置和后置的星号。关于 markdown 文件的好之处在于,我们还可以使用正常的 HTML 标签进行样式设置,就像我们在这里对单词 dwarfed
—进行下标处理一样。
在完成我们的编辑后,我们可以从“编辑文件”选项卡切换到“预览更改”选项卡,查看我们做了什么。预览通过着色和删除线清晰地标记我们的更改:
预览我们的更改
我们现在想要保存这些更改,在 Git 的语言中,这意味着提交。我们可以通过底部的绿色大按钮来完成这个操作。然而,在我们这样做之前,我们必须添加一个包含一个强制性的简短摘要行和一个可选的扩展描述的提交信息。我们可以在“提交更改”下的文本字段中添加这些内容。将提交摘要行视为电子邮件标题,将扩展描述视为电子邮件正文。
一个好的提交摘要行应该简短(少于 70 个字符),并完成句子“这个提交将…”,简洁地总结更改包含的内容。GitHub 非常友好地为我们提供了根据我们所做的更改预定义的描述。在这种情况下,它为我们提供了更新 README.md 的描述,我们对此非常满意,所以我们将不会更改它。我们将留空可选的扩展描述字段,并点击“提交更改”。
将文件上传到仓库
现在让我们上传我们的项目文件和文件夹。回到我们的仓库页面,我们找到上传文件按钮:
上传文件
我们到达一个上传字段,并将所有项目文件拖动过来,就像这样:
拖动我们的文件进行上传
我们的项目由三个文件夹中的四个文件组成。我们在根目录中有一个 planets.html
文件,三个名为 /css
、/data
和 /js
的文件夹包含 planets.css
、planets.csv
和 planets.js
文件。我们只需从我们的文件夹中抓取它们并将它们拖动过来。
提交我们的项目文件和文件夹
一旦上传,我们需要提交我们的更改。我们添加一个简短的提交信息摘要,并点击“提交更改”。
我们将被重定向到仓库页面,并可以看到我们上传的所有文件:
包含所有项目文件的仓库
在 GitHub Pages 上发布您的项目
太好了,您已经在 GitHub 上有了您的项目。它不会更复杂。您只需进行两项小准备和一些点击,就可以将您的项目上线!
准备文件以发布
在我们点击发布按钮之前,我们需要确保您的文件以正确的方式相互链接,并且您的主 HTML 文件被命名为index.html
。
保持路径绝对
首先,让我们检查所有我们的文件引用。在我们的 HTML 文件中有两个链接,一个指向我们的.css
文件,另一个指向我们的.js
文件:
HTML 文件中的正确链接
GitHub 上的所有路径都可以作为指向根目录的绝对路径进行引用。这可以节省您从目录树中的任何文件和任何深度计算相对路径。然而,这也意味着您可能需要将本地目录中的相对路径更改为基于根目录的绝对路径。您的 HTML 文件planets.html
(我们将在下一步将其更改为index.html
)位于根目录中。css
和js
文件夹也保存在根目录中,因此这两个路径相对于根目录是绝对的,并且在本地和 GitHub 上都将有效。
然而,在我们的项目中,我们还在js/planets.js
文件中引用了data/planets.csv
文件。在本地,您可能将其作为相对路径引用,首先使用../
向上移动一个级别,例如'../data/planets.csv'
。然而,在 GitHub 上,以下方式将有效:
在嵌套文件中使用的绝对路径
简而言之,您始终可以在 GitHub 项目中使用相对于根目录的绝对路径。
将主 HTML 文件名更改为 index.html
您的主要 HTML 文件可能已经命名为index.html
。如果不是,只需转到该文件并将其重命名为index.html
,如下所示:
确保我们的主 HTML 文件名为index.html
做得好,这就是所有需要的准备。现在,让我们发布!
发布您的项目
在所有这些艰苦的工作之后,您应该有一个简单的发布过程。嗯,您正好需要五次点击才能在线查看您的项目。这里就是:
- 从您的仓库主页点击设置选项卡:
您的仓库设置
- 导航到 GitHub Pages 区域并展开源字段中的下拉菜单:
您即将完成
- 选择从 master 分支发布(我们没有创建其他分支,所以这就是正确的分支)然后点击保存。
接下来,您将看到以下消息,提供您在线项目的链接:
只需再点击一次!
- 点击它!项目可能需要一段时间才能占据其应有的位置,所以您可能需要等待痛苦的一分钟,但最终,它将被发布:
您发布的作品
太棒了!您的作品已发布,其余的就是历史了!恭喜您。
我们的网址是goodtobehere.github.io/pink-planets/
,或者更普遍地,https://<你的用户名>.github.io/<仓库的名称>
。尽管这是一个逻辑结构,但它可能看起来有点笨拙。你可以将其更改为自定义 URL,就像我们在示例中所做的那样:
自定义域名
这将要求你使用你通过所选 DNS 提供商安全保护的域名,并在 DNS 提供商的一侧以及在某些情况下在 GitHub 上更改一些设置,但这也是一个你可能想要考虑的简单过程。
关于自定义域的出色指导,请参阅help.github.com/articles/using-a-custom-domain-with-github-pages/
。
摘要
在本章中,你学习了如何在 GitHub Pages 上发布你的可视化作品,或者实际上任何项目。你了解了你可以在 GitHub Pages 上发布和不能发布的页面类型。在使用它作为代码的家园之前,你瞥见了 Git 的力量和 GitHub 的益处。经过一些微调和准备,你最终将项目在线发布。看待这一成就的一种方式是,你的项目现在与网络上的任何其他页面拥有相同的在线空间,这是可以构建的。