HTML5-图形和数据可视化秘籍-全-
HTML5 图形和数据可视化秘籍(全)
原文:
zh.annas-archive.org/md5/6DD5FA08597C1F517B2FC929FBC4EC5A
译者:飞龙
前言
今天,网络和世界越来越多地被数据所定义。随着互联网在九十年代初期以及直到今天的数据革命,越来越多的数据被公开和聚合,从政府机构、公共部门信息、金融信息、数字媒体和新闻、社交媒体到私营部门信息、用户信息等等。随着网络上数据的过载,很容易忽视信息,因为以数据格式阅读和分析要困难得多。这就是我们介入的地方。我们在这本书中的目标是向您打开数据可视化的大门。通过逐步指南,您将从基本的视觉图表创建一直到由 Google 地图和 Google 文档(云端硬盘)驱动的复杂地理位置信息。
HTML5 和 JavaScript 正在引领数据可视化的新路径,并且正在将我们从传统的使用 Adobe Flash 创建客户端图形或服务器端生成图像的方式中移开。随着浏览器的成熟,它们变得比以往任何时候都更有能力和稳定。现在是将图表创建转移到 HTML/JavaScript 的绝佳时机。但您应该从哪里开始,以及创建项目所需的特定图表/地图的最佳方式是什么?
话虽如此,我们在这本书中的目标是快速展示并教授 HTML5/JavaScript 数据可视化时代所需的所有关键技能。我们的目标是帮助您在需要构建自定义图形或图表时做出正确选择,并帮助您在创建自己的图形或使用第三方小/大工具创建图形的方式之间做出正确选择。
尽管这是一本食谱,但我已经非常有条理地按主题组织了它,使它从头到尾都很有趣。因此,我个人建议您坐下来实际从头到尾阅读它,如果您这样做,您将在这个过程中学到关于二维画布 API、如何创建形状、交互和各种图表/图表以及如何在 HTML5 画布中从头开始创建它们的一切。您将学会如何使用和修改第三方工具,使用 Google 可视化 API、Google 地图和 Google 文档。在整本书中,我们将涉及各种数据格式,从基本字符串、外部文件、XML 和 Google 文档到 Twitter 搜索结果。因此,您将在 JavaScript 中获得额外的加载、修改和处理数据的练习。
通过本书,您将在数据可视化、图表、数据策略和 HTML5 画布方面建立坚实的工作基础。
本书涵盖内容
第一章 在画布中绘制形状,向您介绍了如何使用画布。在创建图表时,我们将花费大部分时间与画布一起工作。在本章中,我们将重点介绍如何使用二维画布 API 了解画布的工作原理以及如何创建自定义形状。
第二章 画布中的高级绘图,延续了第一章中的内容,我们通过添加各种功能来掌握画布的技能。我们将使用曲线、图像、文本,甚至像素操作。
第三章 创建基于笛卡尔坐标系的图表,展示了我们第一组图表,即基于笛卡尔坐标系的图表。总的来说,这种图表风格相对简单;它为探索数据提供了惊人的创造性方式。在本章中,我们将奠定构建图表的基础,并将继续扩展我们对画布的整体知识。
第四章, 让事物变得曲线,利用创建非线性图表来表示多维数据的能力。在本章中,我们将创建气泡图、饼图、圆环图、雷达图和树图。
第五章, 走出框框,进入更加创新、不常用的图表,并重新审视一些旧图表,以将动态数据或更改其布局整合到其中。在本章中,我们将创建一个漏斗图,为我们的图表添加交互性,创建一个递归树图,添加用户交互,并最后创建一个交互式点击计数器。
第六章, 让静态事物活起来,介绍了 JavaScript 面向对象编程,从头开始创建动画库,添加多层画布,最后创建一个能感知周围环境的图例。这一章将通过首先使一切都变得动态,然后创建一个更面向对象的程序,让我们养成一些新的习惯,这样更容易区分任务并减少我们的代码量。
第七章, 依赖开源领域,向你介绍了各种库。开源数据可视化社区非常丰富和详细,有很多选择和一些真正令人惊叹的库。每个库都有其优点和缺点。有些是独立的代码,而其他的则依赖于其他平台。我们在本章的目标是展示我们认为是最好、最有创意的在线选项,并学习定制第三方工具并扩展其功能超出其可用文档的新技能。
第八章, 与 Google 图表玩耍,逐步探讨了 Google 可视化 API。我们将看看创建图表并将其与图表 API 集成的步骤。在这个过程中,我们将创建新的图表,并探索这个库的核心能力。
第九章, 使用 Google 地图,探讨了 Google 地图上的一些功能,让我们准备好开始使用地图。地图本身并不是数据可视化,但是在我们建立了如何使用地图的基本理解之后,我们将拥有一个非常稳定的背景,使我们能够创建许多集成数据和数据可视化的尖端、酷炫的项目。
第十章, 地图的应用,更深入地与我们的数据可视化和地图主题联系在一起。如今,最流行的数据可视化方式之一是使用地图。在本章中,我们将探讨如何将数据集成到使用 Google 地图平台的地图中的一些想法。
附录,选择你的图形技术,将探讨本书未涵盖的其他替代选项。这个附录的目标是设置环境,让你更好地了解其他图表选项。这个附录不在书中,但可以在以下链接免费下载:
www.packtpub.com/sites/default/files/downloads/3707OT_Appendix_Final.pdf
你需要为这本书做好准备
你需要具备一些 HTML 和 JavaScript 或其他类似编程语言的基本背景知识。
这本书是为谁准备的
这不是一本初学者的书,而是为想要将他们的技能扩展到图表、画布、实践中的面向对象编程、第三方修改以及整体数据策略和数据可视化的 JavaScript 开发人员准备的。
约定
在本书中,您会发现一些区分不同信息类型的文本样式。以下是一些示例以及它们的含义解释。
文本中的代码单词显示如下:“设置我们的grayStyle
样式对象为默认样式:”
代码块设置如下:
var aGray = [
{
stylers: [{saturation: -100}]
}
];
当我们希望引起您对代码块的特定部分的注意时,相关行或项目会以粗体显示:
map.mapTypes.set('grayStyle', grayStyle);
map.setMapTypeId('grayStyle');
新术语和重要单词以粗体显示。屏幕上看到的单词,比如菜单或对话框中的单词,会在文本中以这种方式出现:“从左侧菜单中选择服务选项:”
注意
警告或重要提示会以这种方式出现在框中。
提示
提示和技巧会以这种方式出现。
第一章:在画布中绘制形状
在本章中,我们将涵盖:
-
使用 2D 画布进行图形处理
-
从基本形状开始
-
分层矩形以创建希腊国旗
-
使用路径创建形状
-
创建复杂形状
-
添加更多顶点
-
重叠形状以创建其他形状
介绍
本章的主要重点是突破在画布上工作。在创建图表时,我们将花费大部分时间与画布一起工作。
在本章中,我们将掌握使用画布 API 绘制基本形状和样式。本章将是本书其余部分的图形支柱,因此如果在任何阶段您觉得需要复习,可以回到本章。绘制线条可能...嗯,不是很激动人心。有什么比将主题整合到本章作为一个子主题更能使它更加戏剧化呢:创建旗帜!
使用 2D 画布进行图形处理
画布是 HTML 的主要和最激动人心的补充。这是行业的热点,所以让我们从那里开始。我们将在后面的章节中再次访问画布。在这个示例中,我们将学习如何使用画布动态绘制,并创建一个彩色圆形数组,每秒更新一次。
如何做...
我们将创建两个文件(一个 HTML5 文件和一个 JS 文件)。让我们从创建一个新的 HTML5 文档开始:
- 第一步是创建一个空的 HTML5 文档:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Canvas Example</title>
</head>
<body>
</body>
</html>
提示
下载示例代码
您可以从您在www.PacktPub.com
的帐户中购买的所有 Packt 图书下载示例代码文件。如果您在其他地方购买了本书,您可以访问www.PacktPub.com/support
并注册以直接通过电子邮件接收文件。
代码文件也可以在02geek.com/books/html5-graphics-and-data-visualization-cookbook.html
上找到。
- 创建一个新的画布元素。我们给我们的画布元素一个 ID 为
myCanvas
:
<body>
<canvas id="myCanvas"> </canvas>
</body>
- 将 JavaScript 文件
01.01.canvas.js
导入 HTML 文档(我们将在第 5 步中创建此文件):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<script src="img/01.01.canvas.js"></script>
<title>Canvas Example</title>
</head>
- 添加一个
onLoad
监听器,并在文档加载时触发函数init
:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<script src="img/01.01.canvas.js"></script>
<title>Canvas Example</title>
</head>
<body onLoad="init();" style="margin:0px">
<canvas id="myCanvas" />
</body>
</html>
-
创建
01.01.canvas.js
文件。 -
在 JavaScript 文件中,创建函数
init
并在其中调用函数updateCanvas
:
function init(){
updateCanvas();
}
- 创建函数
updateCanvas
:
function updateCanvas(){
//rest of the code in the next steps will go in here
}
- 在
updateCanvas
函数中(在接下来的步骤中,所有代码都将添加到此函数中),创建两个变量,用于存储您所需的宽度和高度。在我们的情况下,我们将获取窗口的宽度:
function updateCanvas(){
var width = window.innerWidth;
var height = 100;
...
- 访问 HTML 文档中的画布层,并更改其宽度和高度:
var myCanvas = document.getElementById("myCanvas");
myCanvas.width = width;
myCanvas.height = height;
- 获取画布的 2D 上下文:
var context = myCanvas.getContext("2d");
- 创建一个矩形以填充画布的完整可见区域:
context.fillStyle = "#FCEAB8";
context.fillRect(0,0,width,height);
- 让我们创建一些辅助变量,以帮助我们确定要绘制的元素的颜色、大小和数量:
var circleSize=10;
var gaps= circleSize+10;
var widthCount = parseInt(width/gaps);
var heightCount = parseInt(height/gaps);
var aColors=["#43A9D1","#EFA63B","#EF7625","#5E4130"];
var aColorsLength = aColors.length;
- 创建一个嵌套循环,并创建一个随机颜色的圆形网格:
for(var x=0; x<widthCount;x++){
for(var y=0; y<heightCount;y++){
context.fillStyle = aColors[parseInt(Math.random()*aColorsLength)];
context.beginPath();
context.arc(circleSize+gaps*x,circleSize+ gaps*y, circleSize, 0, Math.PI*2, true);
context.closePath();
context.fill();
}
}
}
哇!这是很多步骤!如果您按照所有步骤进行操作,当您运行应用程序时,您将在浏览器中找到许多圆形。
它是如何工作的...
在我们直接进入此应用程序的 JavaScript 部分之前,我们需要触发onLoad
事件以调用我们的init
函数。我们通过将onLoad
属性添加到我们的 HTML body 标签中来实现这一点:
<body onLoad="init();">
让我们分解 JavaScript 部分,并了解这样做的原因。第一步是创建init
函数:
function init(){
updateCanvas();
}
我们的init
函数立即调用updateCanvas
函数。这样做是为了以后可以刷新并再次调用updateCanvas
。
在updateCanvas
函数中,我们首先获取浏览器的当前宽度,并为我们的绘图区域设置一个硬编码值的高度:
var width = window.innerWidth;
var height = 100;
我们的下一步是使用其 ID 获取我们的画布,然后根据先前的变量设置其新的宽度和高度:
var myCanvas = document.getElementById("myCanvas");
myCanvas.width = width;
myCanvas.height = height;
是时候开始绘制了。为了做到这一点,我们需要要求我们的画布返回其上下文。有几种类型的上下文,如 2D 和 3D。在我们的情况下,我们将专注于 2D 上下文,如下所示:
var context = myCanvas.getContext("2d");
现在我们有了上下文,我们有了开始探索和操纵我们的画布所需的一切。在接下来的几个步骤中,我们通过使用十六进制值设置fillStyle
颜色来定义画布的背景颜色,并绘制一个适合整个画布区域的矩形:
var context = myCanvas.getContext("2d");
context.fillStyle = "#FCEAB8";
context.fillRect(0,0,width,height);
fillRect
方法接受四个参数。前两个是矩形的(x,y)位置,在我们的情况下,我们想从(0,0)开始,后面的参数是我们新矩形的宽度和高度。
让我们画我们的圆。为此,我们需要定义我们圆的半径和圆之间的间距。让我们不间隔圆,创建半径为 10 像素的圆。
var rad=10;
var gaps= rad*2;
第一行分配了我们圆的半径,而第二行捕获了我们创建的每个圆的中心之间的间隙,或者在我们的情况下是我们圆的直径。通过将其设置为两倍的半径,我们将我们的圆精确地一个接一个地间隔开。
var widthCount = parseInt(width/gaps);
var heightCount = parseInt(height/gaps);
var aColors=["#43A9D1","#EFA63B","#EF7625","#5E4130"];
var aColorsLength = aColors.length;
使用我们的新gaps
变量,我们发现我们可以在画布组件的宽度和高度上创建多少个圆。我们创建一个存储一些圆的颜色选项的数组,并将变量aColorsLength
设置为aColors
的长度。我们这样做是为了减少处理时间,因为变量比属性更容易获取,因为我们将在我们的for
循环中多次调用这个元素:
for(var x=0; x<widthCount;x++){
for(var y=0; y<heightCount;y++){
context.fillStyle = aColors[parseInt(Math.random()*aColorsLength)];
context.beginPath();
context.arc(rad+gaps*x,rad+ gaps*y, rad, 0, Math.PI*2, true);
context.closePath();
context.fill();
}
}
我们嵌套的for
循环使我们能够创建我们的圆到画布的宽度和高度。第一个for
循环专注于升级宽度值,而第二个for
循环负责遍历每一列。
context.fillStyle = aColors[parseInt(Math.random()*aColorsLength)];
使用Math.random
,我们随机从aColors
中选择一种颜色,用作我们新圆的颜色。
context.beginPath();
context.arc(rad+gaps*x,rad+ gaps*y, rad, 0, Math.PI*2, true);
context.closePath();
在上一段代码的第一行和最后一行声明了一个新形状的创建。beginPath
方法定义了形状的开始,closePath
方法定义了形状的结束,而context.arc
创建了实际的圆。arc
属性采用以下格式的值:
context.arc(x,y,radius,startPoint,endPoint, isCounterClock);
x
和y
属性定义了弧的中心点(在我们的例子中是一个完整的圆)。在我们的for
循环中,我们需要添加额外半径的缓冲区,将我们的内容推入屏幕。我们需要这样做,因为如果我们不通过额外的半径将其推到左边和底部,那么我们第一个圆的四分之一将是可见的。
context.fill();
最后但并非最不重要的是,我们需要调用fill()
方法来填充我们新创建的形状的颜色。
还有更多...
让我们使我们的元素每秒刷新一次;要做到这一点,我们只需要添加两行。第一行将使用setInterval
每秒触发对updateCanvas
函数的新调用。
function init(){
setInterval(updateCanvas,1000);
updateCanvas();
}
如果您刷新浏览器,您会发现我们的示例正在工作。如果您努力寻找问题,您将找不到,但我们有一个问题。这不是一个主要问题,而是一个让我们学习画布的另一个有用功能的绝佳机会。在任何阶段,我们都可以清除画布或其部分。让我们在重新绘制之前清除当前画布,而不是在当前画布上绘制。在updateCanvas
函数中,我们将添加以下突出显示的代码:
var context = myCanvas.getContext("2d");
context.clearRect(0,0,width,height);
一旦我们得到上下文,我们就可以使用clearRect
方法清除已经存在的数据。
另外
- 从基本形状开始食谱
从基本形状开始
在这个阶段,您知道如何创建一个新的画布区域,甚至创建一些基本形状。让我们扩展我们的技能,开始创建旗帜。
准备工作
嗯,我们不会从最基本的旗帜开始,因为那只是一个绿色的矩形。如果您想学习如何创建绿色旗帜,您不需要我,所以让我们转向稍微复杂一点的旗帜。
如果您已经按照使用 2D 画布进行绘图食谱的步骤进行操作,您已经知道如何做了。这个食谱专门为我们帕劳读者和完美的圆弧(也称为圆)而设。
在这个食谱中,我们将忽略 HTML 部分,因此,如果您需要了解如何创建带有 ID 的画布,请返回到本章的第一个食谱,并设置您的 HTML 文档。不要忘记使用正确的 ID 创建画布。您也可以下载我们的示例 HTML 文件。
如何做...
添加以下代码块:
var cnvPalau = document.getElementById("palau");
var wid = cnvPalau.width;
var hei = cnvPalau.height;
var context = cnvPalau.getContext("2d");
context.fillStyle = "#4AADD6";
context.fillRect(0,0,wid,hei);
context.fillStyle = "#FFDE00";
context.arc(wid/2.3, hei/2, 40, 0, 2 * Math.PI, false);
context.fill();
就是这样,你刚刚创建了一个完美的圆弧,以及你的第一个具有形状的国旗。
它是如何工作的...
在这个阶段,这段代码的大部分内容应该看起来非常熟悉。因此,我将重点放在与本章第一个食谱中使用的代码相比的新行上。
var wid = cnvPalau.width;
var hei = cnvPalau.height;
在这些行中,我们提取了画布的宽度和高度。我们有两个目标:缩短我们的代码行数,减少不必要的 API 调用次数。由于我们使用它超过一次,我们首先获取这些值并将它们存储在wid
和hei
中。
现在我们知道了画布的宽度和高度,是时候画我们的圆圈了。在开始绘制之前,我们将调用fillStyle
方法来定义在画布中使用的背景颜色,然后我们将创建圆弧,最后触发fill
方法来完成。
context.fillStyle = "#FFDE00";
context.arc(wid/2.3, hei/2, 40, 0, 2 * Math.PI, false);
context.fill();
然后,我们使用arc
方法创建我们的第一个完美圆圈。重要的是要注意,我们可以在任何时候更改颜色,例如在这种情况下,我们在创建新圆圈之前更改颜色。
让我们更深入地了解一下arc
方法的工作原理。我们首先通过x
和y
位置定义我们圆圈的中心。画布标签遵循标准的笛卡尔坐标:(0,0)在左上角(x
向右增长,y
向底部增长)。
context.arc(x, y, radius, startingAngle, endingAngle, ccw);
在我们的示例中,我们决定通过将画布的宽度除以2.3
来将圆圈略微定位到中心的左侧,并将y
定位在画布的正中心。下一个参数是我们圆圈的半径,接下来是两个参数,定义了我们描边的起始和结束位置。由于我们想要创建一个完整的圆圈,我们从0
开始,到两倍的Math.PI
结束,即一个完整的圆圈(Math.PI
相当于 180 度)。最后一个参数是我们圆弧的方向。在我们的情况下,由于我们正在创建一个完整的圆圈,设置在这里无关紧要(ccw = 逆时针)。
context.fill();
最后但同样重要的是,我们调用fill
函数来填充和着色我们之前创建的形状。与fillRect
函数不同,它既创建又填充形状,arc
方法不会。arc
方法只定义要填充的形状的边界。您可以使用这种方法(和其他方法)在实际绘制到舞台之前创建更复杂的形状。我们将在本章的后续食谱中更深入地探讨这一点。
层叠矩形以创建希腊国旗
我们在为帕劳创建国旗时学到,当我们使用arc
方法创建一个圆圈时,我们必须单独触发一个请求来填充形状。这对我们从头开始创建的所有形状都是如此,对于创建线条也是如此。让我们转向一个稍微复杂一点的国旗:希腊国旗。
准备工作
与上一个食谱一样,我们将跳过 HTML 部分,直接进入绘制画布的 JavaScript 部分。有关创建画布元素所涉及的步骤的详细说明,请参考本章的第一个食谱。
在开始编码之前,仔细观察国旗,并尝试制定一个攻击计划,列出创建这面国旗所需执行的步骤。
如何做...
如果我们看一下旗帜,很容易就能想出如何规划这个过程。有很多方法可以做到这一点,但以下是我们的尝试:
- 我们首先启动我们的应用程序,并创建一个空白的蓝色画布:
var canvas = document.getElementById("greece");
var wid = canvas.width;
var hei = canvas.height;
var context = canvas.getContext("2d");
context.fillStyle = "#000080";
context.fillRect(0,0,wid,hei);
- 如果你看一下前面的图,有四条白色条纹和五条蓝色条纹将成为背景的一部分。让我们将画布的总高度除以
9
,这样我们就可以找到我们线条的合适大小:
var lineHeight = hei/9;
- 到目前为止,我们使用内置工具创建了形状,比如
arc
和fillRect
。现在我们要手动绘制线条,为此我们将设置lineWidth
和strokeStyle
的值,这样我们就可以在画布上绘制线条:
context.lineWidth = lineHeight;
context.strokeStyle = "#ffffff";
- 现在,让我们循环四次,创建一条从右侧到左侧的线,如下所示:
var offset = lineHeight/2;
for(var i=1; i<8; i+=2){
context.moveTo(0,i*lineHeight + offset);
context.lineTo(wid,i*lineHeight+offset);
}
就是这样,我们成功了。重新加载你的 HTML 页面,你会发现希腊的国旗以其全部的荣耀展现在那里。嗯,还不是全部的荣耀,但足够让你猜到这是希腊的国旗。在我们继续之前,让我们深入了解一下这是如何工作的。
它是如何工作的...
注意偏移量的增加。这是因为lineWidth
从线的中心点向两个方向增长。换句话说,如果从(0, 0)到(0, height)绘制宽度为 20 像素的线条,那么只有 10 像素可见,因为线条的厚度范围在(-10 到 10)之间。因此,我们需要考虑到我们的第一条线需要被其宽度的一半向下推,这样它就在正确的位置上了。
moveTo
函数接受两个参数moveTo(x,y)
。lineTo
函数也接受两个参数。我相信你一定已经猜到它们之间的区别了。一个会移动虚拟点而不绘制任何东西,而另一个会在点之间创建一条线。
还有更多...
如果你运行你的 HTML 文件,你会发现我们的线条没有显示出来。别担心,你没有犯错(至少我是这么认为的;))。为了让线条变得可见,我们需要告诉浏览器我们已经准备好了,就像我们在使用arc
时调用fill()
方法一样。在这种情况下,由于我们正在创建线条,我们将在定义完线条后立即调用stroke()
方法,如下所示:
var offset = lineHeight/2;
for(var i=1; i<8; i+=2){
context.moveTo(0,i*lineHeight + offset);
context.lineTo(wid,i*lineHeight+offset);
}
context.stroke();
如果你现在刷新屏幕,你会发现我们已经离成功更近了。现在是时候在屏幕的左上角添加那个矩形了。为此,我们将重用我们的lineHeight
变量。我们的矩形的大小是lineHeight
长度的五倍:
context.fillRect(0,0,lineHeight*5,lineHeight*5);
现在是时候在旗帜上创建十字了:
context.moveTo(0, lineHeight*2.5);
context.lineTo(lineHeight*5,lineHeight*2.5);
context.moveTo(lineHeight*2.5,0);
context.lineTo(lineHeight*2.5,lineHeight*5+1);
context.stroke();
如果你现在运行应用程序,你会感到非常失望。我们完全按照之前学到的内容去做了,但结果并不如预期。
线条都混在一起了!好吧,别害怕,这意味着是时候学习新东西了。
beginPath 方法和 closePath 方法
我们的旗帜效果不太好,因为它被我们之前创建的所有线搞混了。为了避免这种情况,我们应该告诉画布我们何时开始新的绘图,何时结束。为此,我们可以调用beginPath
和closePath
方法,让画布知道我们已经完成了某些事情或者正在开始新的事情。在我们的情况下,通过添加beginPath
方法,我们可以解决我们的旗帜问题。
context.fillRect(0,0,lineHeight*5,lineHeight*5);
context.beginPath();
context.moveTo(0, lineHeight*2.5);
context.lineTo(lineHeight*5,lineHeight*2.5);
context.moveTo(lineHeight*2.5,0);
context.lineTo(lineHeight*2.5,lineHeight*5+1);
context.stroke();
恭喜!你刚刚创建了你的前两个国旗,并且在这个过程中学到了很多关于画布 API 的知识。这已经足够让你能够从 196 个国旗中创建 53 个国家的国旗。这已经是一个很好的开始;世界上 25%的国家都在你手中。
你现在应该能够做的最复杂的旗帜是英国的国旗。如果你想探索一下,试试看。如果你真的为此感到自豪,请给我写封邮件<ben@02geek.com>
,我会很乐意看到它。
使用路径创建形状
我们在上一个教程中学习了如何创建世界国旗四分之一的内容,但这并不能结束,对吧?这个教程将致力于使用路径创建更复杂的形状。我们将从创建一个三角形开始,然后逐渐进展到更复杂的形状。
做好准备
让我们从基本形状库中不包括的最简单的形状开始:三角形。所以,如果你准备好了,让我们开始吧...
如何做...
让我们从创建我们的第一个形状开始,一个三角形:
context.fillStyle = color;
context.beginPath();
context.moveTo(x1,y1);
context.lineTo(x2,y2);
context.lineTo(x3,y3);
context.lineTo(x1,y1);
context.closePath();
context.fill();
这里的代码中的点 x1,y1
到 x3,y3
是伪代码。你需要选择自己的点来创建一个三角形。
工作原理...
这里的大部分元素都不是新的。这里最重要的变化是,我们正在使用之前使用过的元素从头开始创建形状。当我们创建一个形状时,我们总是从使用 beginPath()
方法声明它开始。然后我们创建形状,并使用 closePath()
方法结束创建。在屏幕上我们仍然看不到任何东西,直到我们决定我们想要对我们创建的形状做什么,比如显示它的填充或显示它的描边。在这种情况下,因为我们试图创建一个三角形,我们将调用 fill
函数。
让我们在一个真实的国旗示例中看看它的运行情况。这次我们将参观圭亚那的罗赖马山。
好的,你已经了解了三角形的概念。让我们看看它的实际应用。我提取了这段代码并将其放入一个函数中。要创建这个国旗,我们需要创建四个三角形。
var canvas = document.getElementById("guyana");
var wid = canvas.width;
var hei = canvas.height;
var context = canvas.getContext("2d");
context.fillStyle = "#009E49";
context.fillRect(0,0,wid,hei);
fillTriangle(context, 0,0,
wid,hei/2,
0,hei, "#ffffff");
fillTriangle(context,0,10,
wid-25,hei/2,
0,hei-10, "#FCD116");
fillTriangle(context,0,0,
wid/2,hei/2,
0,hei, "#000000");
fillTriangle(context,0,10,
wid/2-16,hei/2,
0,hei-10, "#CE1126");
function fillTriangle(context,x1,y1,x2,y2,x3,y3,color){
context.fillStyle = color;
context.beginPath();
context.moveTo(x1,y1);
context.lineTo(x2,y2);
context.lineTo(x3,y3);
context.lineTo(x1,y1);
context.closePath();
context.fill();
}
通过创建 fillTriangle()
函数,我们现在可以快速有效地创建三角形,就像我们创建矩形一样。这个函数使得创建一个有如此丰富数量的三角形的国旗变得轻而易举。现在,借助 fillTriangle
方法的帮助,我们可以创建世界上任何有三角形的国旗。
还有更多...
不要让三角形成为你最复杂的形状,因为你可以创建任意数量的尖锐形状。让我们创建一个更复杂的锯齿形图案。为此,我们将飞到巴林王国。
试着找到我们分解和解释之前的新逻辑。
var canvas = document.getElementById("bahrain");
var wid = canvas.width;
var hei = canvas.height;
var context = canvas.getContext("2d");
context.fillStyle = "#CE1126";
context.fillRect(0,0,wid,hei);
var baseX = wid*.25;
context.fillStyle = "#ffffff";
context.beginPath();
context.lineTo(baseX,0);
var zagHeight = hei/5;
for(var i=0; i<5; i++){
context.lineTo(baseX +25 , (i+.5)*zagHeight);
context.lineTo(baseX , (i+1)*zagHeight);
}
context.lineTo(0,hei);
context.lineTo(0,0);
context.closePath();
context.fill();
addBoarder(context,wid,hei);
让我们分解这个锯齿形并理解这里发生了什么。在正常设置画布元素后,我们立即开始创建我们的形状。我们首先绘制一个红色背景,然后创建一个将有白色区域的形状。它非常像一个矩形,只是它里面有锯齿。
在这段代码中,我们首先创建一个矩形,但我们的目标是改变突出显示的代码行,使其成为锯齿形:
var baseX = wid*.25;
context.fillStyle = "#ffffff";
context.beginPath();
context.lineTo(baseX,0);
context.lineTo(wid*.25,hei);
context.lineTo(0,hei);
context.lineTo(0,0);
context.closePath();
context.fill();
在这段代码中,我们将填充颜色设置为白色,我们设置了 beginPath
,然后 lineTo
(从点 (0,0)
开始,即默认起始点)并创建一个填充了画布宽度 25% 的矩形。我突出了水平线,因为这是我们想要用锯齿形的线。通过观察国旗,我们可以看到我们将在屏幕上创建五个三角形,所以让我们用 for
循环来替换这条线:
...
context.lineTo(baseX,0);
var zagHeight = hei/5;
for(var i=0; i<5; i++){
context.lineTo(baseX +25 , (i+.5)*zagHeight);
context.lineTo(baseX , (i+1)*zagHeight);
}
context.lineTo(0,hei);
...
因此,在我们运行循环之前,我们的第一步是决定每个三角形的高度:
var zagHeight = hei/5;
我们将画布的总高度除以五,得到每个三角形的高度。
我们在 for
循环中绘制了锯齿形。为此,我们需要在每一轮中使用以下两行代码:
context.lineTo(baseX +25 , (i+.5)*zagHeight);
context.lineTo(baseX , (i+1)*zagHeight);
在第一行中,我们远离当前位置,并将线条延伸到三角形高度的一半,并延伸到右侧的极点;然后在第二行中,我们返回到起始的 x
点,并更新我们的 y
到下一行段的起始点。顺便说一句,baseX +25
的添加是完全任意的。我只是随意尝试,直到看起来不错,但如果你愿意,你可以使用比例来代替(这样如果你扩展画布,它看起来仍然很好)。
所有这一切最令人惊奇的部分就是知道如何创建一些锯齿、三角形、矩形和圆。你可以创建更多的国旗,但我们还没有完成。我们继续追求如何创建世界上所有国旗的知识。
如果您是第一次通过代码绘图,或者觉得自己需要一些额外的练习,只需查看世界地图,并挑战自己根据我们已经建立的技能创建国旗。
创建复杂形状
现在是时候将我们学到的一切融入到迄今为止我们见过的最复杂的形状中,即大卫之星。这颗星星是以色列国旗的一部分(世界上我最喜欢的国旗之一;))。在我们能够创建它之前,我们需要绕个圈,访问正弦和余弦的神奇世界。
你一定会喜欢它,对吧?我知道很多人害怕余弦和正弦,但实际上它们非常容易和有趣。让我们在这里以一种更适合绘图的方式来解释它们。最基本的想法是你有一个有一个 90 度角的三角形。你对这个三角形有一些信息,这就是你开始使用正弦和余弦的全部所需。一旦你知道你有一个 90 度角并且知道正弦/余弦,你就有了所有你需要的信息,通过它你可以发现任何缺失的信息。在我们的情况下,我们知道所有的角度,我们知道斜边的长度(它就是我们的半径;看看带有圆的图像,看看它是如何运作的)。在 JavaScript 中,Math.cos()
和Math.sin()
方法都代表一个半径为 1 的圆,位于屏幕上的(0,0)点。如果我们将要查找的角度输入到sin
函数中,它将返回x
值(在这种情况下是邻边的长度),cos
函数将返回对边的长度,在我们的情况下是所需的值y
。
我制作了一个很好的视频,深入探讨了这个逻辑。你可以在02geek.com/courses/video/58/467/Using-Cos-and-Sin-to-animate.html
上查看它。
准备就绪
理解正弦/余弦工作的最简单方法是通过一个实时的例子,而在我们的情况下,我们将用它来帮助我们弄清楚如何在以色列国旗中创建大卫之星。我们将退一步,学习如何找到屏幕上的点来创建形状。同样,我们将跳过创建 HTML 文件的过程,直接进入 JavaScript 代码。有关如何设置 HTML 的概述,请查看使用 2D 画布进行图形处理配方。
如何做...
在创建 JavaScript 文件后,在您的init
函数中添加以下代码。
- 创建我们基本的画布变量:
var canvas = document.getElementById("israel");
var wid = canvas.width;
var hei = canvas.height;
var context = canvas.getContext("2d");
- 定义弧度中的一度。我们这样做是因为
Math.cos
和Math.sin
期望的是弧度值而不是度值(radian
是以弧度测量的一度):
var radian = Math.PI/180;
- 创建一个
tilt
变量。这个变量将定义将要创建的三角形的倾斜。想象三角形在一个圆内,我们正在用这个tilt
变量旋转圆:
var tilt = radian*180;
- 定义画布的中心点:
var baseX = wid / 2;
var baseY = hei / 2;
- 设置大卫之星的无形边界圆的半径:
var radius = 24;
- 定义国旗中条纹的高度:
var stripHeight = 14;
- 定义线宽:
context.lineWidth=5;
- 创建两个三角形(一个倾斜,一个不倾斜):
createTrinagle(context,
baseX+ Math.sin(0) * radius, baseY + Math.cos(0) * radius,
baseX+ Math.sin(radian*120) * radius, baseY + Math.cos(radian*120) * radius,
baseX+ Math.sin(radian*240) * radius, baseY + Math.cos(radian*240) * radius,
null,"#0040C0");
createTrinagle(context,
baseX+ Math.sin(tilt) * radius, baseY + Math.cos(tilt) * radius,
baseX+ Math.sin(radian*120+tilt) * radius, baseY + Math.cos(radian*120+tilt) * radius,
baseX+ Math.sin(radian*240+tilt) * radius, baseY + Math.cos(radian*240+tilt) * radius,
null,"#0040C0");
- 绘制国旗条纹:
context.lineWidth=stripHeight;
context.beginPath();
context.moveTo(0,stripHeight);
context.lineTo(wid,stripHeight);
context.moveTo(0,hei- stripHeight);
context.lineTo(wid,hei- stripHeight);
context.closePath();
context.stroke();
- 创建
createTriangle
函数:
function createTriangle(context,x1,y1,x2,y2,x3,y3,fillColor,strokeColor){
context.beginPath();
context.moveTo(x1,y1);
context.lineTo(x2,y2);
context.lineTo(x3,y3);
context.lineTo(x1,y1);
context.closePath();
if(fillColor) {
context.fillStyle = fillColor;
context.fill();
}
if(strokeColor){
context.strokeStyle = strokeColor;
context.stroke();
}
}
你完成了。运行你的应用程序,你会发现以色列国旗,中间有大卫之星。
它是如何工作的...
在我们深入探讨国旗的创建和如何完成它之前,我们需要了解如何在圆中定位点。为此,让我们看一个更简单的例子:
var rad = Math.PI/180;
context.fillStyle = "#FFDE00";
context.arc(wid / 2, hei / 2, 30, 0, 2 * Math.PI, false);
context.fill();
context.beginPath();
context.strokeStyle = "#ff0000";
context.lineWidth=6;
context.moveTo(Math.sin(0) * 30 + wid / 2, Math.cos(0) * 30 + hei/2);
context.lineTo(Math.sin(rad*120) * 30 + wid / 2, Math.cos(rad*120) * 30 + hei/2);
context.stroke();
以下是代码将生成的输出:
尽管在我们人类友好的头脑中,一个圆是一个有 360 度的形状,但实际上在大多数编程语言中最好用弧度表示。
弧度就像度数一样,只是它们不是人类友好的 0 到 360 之间的数字,而是 0 到两倍 Pi 之间的数字。你可能想知道 Pi 是什么,所以再多说一点关于 Pi。Pi 本质上是当你取任何圆的周长并将其除以相同圆的直径时得到的值。返回的结果将是 Pi 或约为 3.14159。这是一个神奇的数字,好消息是,如果你不想知道更多,你就不需要知道更多。你只需要知道 3.142 等于半个圆。有了这个事实,我们现在可以将 Pi 除以180
得到一个弧度值等于一度的值:
var rad = Math.PI/180;
然后我们在屏幕中心创建一个半径为30
的圆,以帮助我们可视化,然后开始创建一条线,该线将从我们圆的角度0
开始,到角度120
结束(因为我们想创建一个 360/3 的三角形)。
context.strokeStyle = "#ff0000";
context.lineWidth=6;
context.moveTo(Math.sin(0) * 30 + wid / 2, Math.cos(0) * 30 + hei/2);
context.lineTo(Math.sin(rad*120) * 30 + wid / 2, Math.cos(rad*120) * 30 + hei/2);
context.stroke();
让我们分解最复杂的那行代码:
context.lineTo(Math.sin(rad*120) * 30 + wid / 2, Math.cos(rad*120) * 30 + hei/2);
由于Math.sin
和Math.cos
返回半径为1
的值,我们将乘以我们圆的半径(在本例中为30
)返回的任何值。在Math.sin
和Math.cos
的参数中,我们将提供完全相同的值;在这个例子中是120
弧度。由于我们的圆将位于画布的左上角,我们希望通过添加到我们的值wid/2
和hei/2
来将圆移到屏幕中心开始。
在这个阶段,你应该知道如何在圆上找到点,以及如何在两点之间画线。让我们回到以色列国旗,深入研究新函数createTriangle
。它是基于使用路径创建形状食谱中创建的fillTriangle
函数。
function createTriangle(context,x1,y1,x2,y2,x3,y3,fillColor,strokeColor){
...
if(fillColor) {
context.fillStyle = fillColor;
context.fill();
}
if(stokeColor){
context.strokeStyle = fillColor;
context.stroke();
}
}
我已经突出显示了这个函数的新组件,与函数fillTriangle
相比。两个新参数fillColor
和strokeColor
定义了我们是否应该填充或描边三角形。请注意,我们将strokeStyle
和fillStyle
方法移到函数底部,以减少我们的代码量。太棒了!我们现在有了一个现代的三角形创建器,可以处理大卫之星。
还有更多...
好的,是时候连接这些点(字面意思)并创建以色列国旗了。回顾我们的原始代码,我们发现自己使用createTriangle
函数两次来创建完整的大卫之星形状。让我们深入研究一下这里的逻辑,看看第二个三角形(倒置的那个):
createTriangle(context,
baseX+ Math.sin(tilt) * radius,
baseY + Math.cos(tilt) * radius,
baseX+ Math.sin(radian*120+tilt) * radius,
baseY + Math.cos(radian*120+tilt) * radius,
baseX+ Math.sin(radian*240+tilt) * radius,
baseY + Math.cos(radian*240+tilt) * radius, null,"#0040C0");
我们发送三个点到虚拟圆上创建一个三角形。我们将虚拟圆分成三等份,并找到0
、120
和240
度的点值。这样,如果我们在这些点之间画一条线,我们将得到一个完美的三角形,其中所有边都是相等的。
让我们深入研究一下发送到createTriangle
函数的一个点:
baseX + Math.sin(radian*120+tilt) * radius,
baseY + Math.cos(radian*120+tilt) * radius
我们从baseX
和baseY
(屏幕中心)开始作为我们圆的中心点,然后找出从基本起始点到实际点间的间隙。然后分别从中加上我们从Math.sin
和Math.cos
得到的值。在这个例子中,我们试图得到120
度加上倾斜值。换句话说,120
度加上180
度(或300
度)。
为了更容易理解,在伪代码中,它看起来类似于以下代码片段:
startingPositionX + Math.sin(wantedDegree) * Radius
startingPositionY + Math.cin(wantedDegree) * Radius
除了祝贺之外,没有更多要说的了。我们刚刚完成了另一面国旗的创建,并在这个过程中学会了如何创建复杂的形状,使用数学来帮助我们找出屏幕上的点,并混合不同的形状来创建更复杂的形状。
添加更多顶点
有许多国旗包含星星,这些星星无法通过重叠的三角形来创建。在这个示例中,我们将找出如何创建一个包含任意数量顶点的星星。我们将利用在上一个示例中发现的相同关键概念,利用虚拟圆来计算位置,这次只用两个虚拟圆。在这个示例中,我们将创建索马里的国旗,并在此过程中找出如何创建一个能够创建星星的函数。
准备就绪
请继续在上一个示例中工作。如果您还没有开始,请务必这样做,因为这个示例是上一个示例的下一个逻辑步骤。与上一个示例一样,我们将跳过此示例的 HTML 部分。请查看本书中的第一个示例,以刷新所需的 HTML 代码。
如何做...
让我们开始创建索马里的国旗。
- 创建画布的标准逻辑:
var canvas = document.getElementById("somalia");
var wid = canvas.width;
var hei = canvas.height;
var context = canvas.getContext("2d");
- 填充画布的背景颜色:
context.fillStyle = "#4189DD";
context.fillRect(0,0,wid,hei);
- 通过调用
createStar
函数来绘制星星:
createStar(context,wid/2,hei/2,7,20,5,"#ffffff",null,0);
- 创建
createStart
函数:
function createStar(context,baseX,baseY,
innerRadius,outerRadius,
points,fillColor,
strokeColor,tilt){
// all the rest of the code in here
}
- 从这一点开始,我们将在
createStart
函数中进行工作。添加一些辅助变量:
function createStar(context,baseX,baseY,innerRadius,outerRadius,points,fillColor,strokeColor,tilt){
var radian = Math.PI/180;
var radianStepper = radian * ( 360/points) /2;
var currentRadian =0;
var radianTilt = tilt*radian;
- 在开始绘制任何形状之前,调用
beginPath
方法:
context.beginPath();
- 将绘图指针移动到内部圆圈的角度
0
:
context.moveTo(baseX+ Math.sin(currentRadian + radianTilt) * innerRadius,baseY+ Math.cos(currentRadian + radianTilt) * innerRadius);
- 循环遍历星星的总点数,并在外圆和内圆之间来回绘制线条,以创建星形:
for(var i=0; i<points; i++){
currentRadian += radianStepper;
context.lineTo(baseX+ Math.sin(currentRadian + radianTilt) * outerRadius,baseY+ Math.cos(currentRadian + radianTilt) * outerRadius);
currentRadian += radianStepper;
context.lineTo(baseX+ Math.sin(currentRadian + radianTilt) * innerRadius,baseY+ Math.cos(currentRadian + radianTilt) * innerRadius);
}
- 关闭绘图路径,并根据函数参数进行填充或描边:
context.closePath();
if(fillColor){
context.fillStyle = fillColor;
context.fill();
}
if(strokeColor){
context.strokeStyle = strokeColor;
context.stroke();
}
}
当您运行 HTML 包装器时,您将找到您的第一个星星,随之而来的是另一面国旗。
它是如何工作的...
让我们首先了解我们要创建的函数期望的内容。这个想法很简单,为了创建一个星形,我们希望有一个虚拟的内圆和一个虚拟的外圆。然后我们可以在圆圈之间来回绘制线条,以创建星形。为此,我们需要一些基本参数。
function createStar(context,baseX,baseY,
innerRadius,outerRaduis,points,fillColor,
strokeColor,tilt){
我们的常规上下文,baseX
和baseY
不需要进一步介绍。虚拟的innerRadius
和outerRadius
用于帮助定义创建星星的线段的长度和它们的位置。我们想知道我们的星星将有多少个点。我们通过添加points
参数来实现。我们想知道fillColor
和/或strokeColor
,这样我们就可以定义星星的实际颜色。我们用tilt
值来完成(当我们为以色列国旗创建大卫之星时,它可能很有用)。
var radian = Math.PI/180;
var radianStepper = radian * ( 360/points) / 2;
var currentRadian =0;
var radianTilt = tilt*radian;
然后,我们继续配置我们星星的辅助变量。这不是我们第一次看到弧度变量,但这是我们第一次看到radianStepper
。弧度步进器的目标是简化我们循环中的计算。我们将 360 度除以我们的三角形将具有的点数。我们将该值除以2
,因为我们将有两倍于线条的点数。最后但并非最不重要的是,我们希望将该值转换为弧度,因此我们通过我们的弧度变量复制完整的结果。然后我们创建一个简单的currentRadian
变量来存储我们目前所处的步骤,并最后将tilt
值转换为弧度值,这样我们就可以在循环中添加到所有我们的线条中而无需额外的计算。
像往常一样,我们使用beginPath
和closePath
方法开始和完成我们的形状。让我们更深入地看一下我们即将形成的形状的起始位置:
context.moveTo(baseX+ Math.sin(currentRadian + radianTilt) * innerRadius,baseY+ Math.cos(currentRadian + radianTilt) * innerRadius);
虽然乍一看这可能有点吓人,但实际上与我们创建大卫之星的方式非常相似。我们从currentRadian
(目前为0
)开始,使用innerRadius
作为起点。
在我们的循环中,我们的目标是在内部和外部圆圈之间来回织线。为此,我们需要在每次循环周期中通过radianStepper
来推进currentRadian
值:
for(var i=0; i<points; i++){
currentRadian += radianStepper;
context.lineTo(baseX+ Math.sin(currentRadian + radianTilt) * outerRadius,baseY+ Math.cos(currentRadian + radianTilt) * outerRadius);
currentRadian += radianStepper;
context.lineTo(baseX+ Math.sin(currentRadian + radianTilt) * innerRadius,baseY+ Math.cos(currentRadian + radianTilt) * innerRadius);
}
我们根据参数中的点数开始一个循环。在这个循环中,我们在内圆和外圆之间来回绘制两条线,每次步进大小由点数(我们用radianStepper
变量配置的值)定义。
在之前的教程中,当我们创建createTriangle
函数时,我们已经涵盖了其余的功能。就是这样!现在你可以运行应用程序并找到我们的第七面旗帜。有了这个新的复杂函数,我们可以创建所有实心星星和所有镂空的非实心星星。
好了,我希望你坐下...有了新获得的星星能力,你现在可以创建至少 109 面旗帜,包括美利坚合众国和所有其他国家的旗帜(世界上 57%的国家,而且还在增加!)。
重叠形状创建其他形状
目前为止,我们已经创建了许多旗帜和许多一般形状,这些形状可以通过组合我们迄今为止创建的形状来创建。在 82 面我们不知道如何创建的最受欢迎的形状之一是土耳其国旗中的新月形状。通过它,我们学会了使用减法来创建更深入的形状。
准备工作
前一个教程是本教程的起点。从这里开始,我们将继续努力创建更复杂的形状,这些形状是由两个形状组合而成的。因此,我们将使用上一个教程中创建的代码,位于01.02.flags.js
中。
如何做...
让我们直接跳到我们的代码中,看看它是如何运作的。
- 获取上下文并将画布的宽度和高度保存到变量中:
var canvas = document.getElementById("turkey");
var wid = canvas.width;
var hei = canvas.height;
var context = canvas.getContext("2d");
- 填充矩形画布区域:
context.fillStyle = "#E30A17";
context.fillRect(0,0,wid,hei);
- 创建一个完整的圆:
context.fillStyle = "#ffffff";
context.beginPath();
context.arc(wid / 2 - 23, hei / 2, 23, 0, 2 * Math.PI, false);
context.closePath();
context.fill();
- 更改画布填充的颜色。用另一个圆填充其边界内的圆,隐藏了上一个创建的圆的一部分。这种效果创建了一个看起来像新月的形状:
context.fillStyle = "#E30A17";
context.beginPath();
context.arc(wid / 2 - 18, hei / 2, 19, 0, 2 * Math.PI, false);
context.closePath();
context.fill();
- 重复使用前一个教程中的
createStart
来添加土耳其星:
createStar(context,wid/2 + 13,hei/2,5,16,5,"#ffffff",null,15);
就是这样!你刚刚创建了一个不可能的形状,这是通过用一个形状遮罩另一个形状实现的。
它是如何工作的...
这里的关键是我们使用了两个圆,一个覆盖另一个来创建新月形状。顺便说一句,注意我们如何倾斜星星,以便其一个点指向圆的中心。
在过去的几个示例中,我们已经经历了很多,此时你应该非常熟悉在画布中创建许多形状和元素。在我们可以说我们已经掌握了画布之前,还有很多东西可以探索,但我们绝对可以说我们已经掌握了大部分世界旗帜的创建,这非常酷。我很想看到你的旗帜。当你创建了一面书中没有的旗帜时,给我留言! 😃
第二章:画布中的高级绘图
-
绘制弧线
-
使用控制点绘制曲线
-
创建贝塞尔曲线
-
将图像整合到我们的艺术中
-
使用文本绘制
-
理解像素操作
介绍
这是最后一章,我们将深入研究画布,因为剩下的章节将专注于构建图表和交互。
在本章中,我们将继续通过向画布添加曲线、图像、文本,甚至像素操作来掌握我们的技能。
绘制弧线
我们可以在画布中创建三种类型的曲线 - 使用弧线、二次曲线和贝塞尔曲线。让我们开始吧。
准备工作
如果您回忆一下第一章,画布中的形状绘制,在我们的第一个示例中,我们使用弧线方法创建了完美的圆圈。弧线方法不仅仅是如此。我们实际上可以在圆形中创建任何部分曲线。如果您不记得绘制圆圈,我强烈建议您再次浏览第一章 ,画布中的形状绘制,同时您也会找到创建 HTML 文档的模板。在本示例中,我们将专门关注 JavaScript 代码。
如何做...
让我们开始并创建我们的第一个具有曲线的非圆形:
- 访问
pacman
画布元素,并使用以下代码片段获取其宽度和高度:
var canvas = document.getElementById("pacman");
var wid = canvas.width;
var hei = canvas.height;
- 创建一个
radian
变量(一度的弧度):
var radian = Math.PI/180;
- 获取画布上下文,并使用以下代码片段将其背景填充为黑色:
var context = canvas.getContext("2d");
context.fillStyle = "#000000";
context.fillRect(0,0,wid,hei);
- 在开始绘制之前开始一个新路径:
context.beginPath();
- 更改填充样式颜色:
context.fillStyle = "#F3F100";
- 将指针移动到屏幕中心:
context.moveTo(wid/2,hei/2);
- 绘制一个从 40 度开始到 320 度结束的曲线(半径为 40),位于屏幕中心:
context.arc(wid / 2, hei / 2, 40, 40*radian, 320*radian, false);
- 通过使用以下代码片段,关闭形状,绘制一条线回到我们形状的起始点:
context.lineTo(wid/2,hei/2);
- 关闭路径并填充形状:
context.closePath();
context.fill();
您刚刚创建了一个 PacMan。
如何做...
第一次,我们利用并创建了一个饼状形状,称为 PacMan(当我们开始创建饼图时,您可以看到这是非常有用的)。非常简单 - 再次连接到弧度的概念:
context.arc(wid / 2, hei / 2, 40, 40*radian, 320*radian, false);
请注意我们的第 4 和第 5 个参数 - 而不是从 0 开始到2*Math.PI
结束的完整圆圈 - 正在设置弧线开始的角度为弧度 40,结束于弧度 320(留下 80 度来创建 PacMan 的嘴)。剩下的就是从圆的中心开始绘制:
context.moveTo(wid/2,hei/2);
context.arc(wid / 2, hei / 2, 40, 40*radian, 320*radian, false);
context.lineTo(wid/2,hei/2);
我们首先将指针移动到圆的中心。然后创建弧线。由于我们的弧线不是完整的形状,它会继续我们离开的地方 - 从弧线的中心到起始点(40 度)画一条线。我们通过画一条线回到弧线的中心来完成动作。现在我们准备填充它并完成我们的工作。
既然我们已经解决了弧线问题,您可以看到这对于创建饼图将会非常有用。
使用控制点绘制曲线
如果世界上只有两个点和一个完美的弧线,那么这将是本书的结尾,但不幸或幸运的是,对我们来说,还有许多更复杂的形状需要学习和探索。有许多曲线不是完全对齐的曲线。到目前为止,我们创建的所有曲线都是完美圆的一部分,但现在不再是这样了。在本示例中,我们将探索二次曲线。二次曲线使我们能够创建不是圆形的曲线,通过添加第三个点 - 控制器来控制曲线。您可以通过查看以下图表轻松理解这一点:
二次曲线是一条具有一个控制点的曲线。考虑这样一种情况,当创建一条线时,我们在两点(本示例中的 A 和 B)之间绘制它。当我们想要创建一个二次曲线时,我们使用一个外部重力控制器来定义曲线的方向,而中间线(虚线)定义了曲线的延伸距离。
准备工作
与以前的示例一样,我们在这里也跳过了 HTML 部分——并不是说它不需要,只是每个示例中都重复出现,如果您需要了解如何设置 HTML,请参阅第一章中的使用 2D 画布绘图示例,在画布中绘制形状。
如何做...
在这个示例中,我们将创建一个看起来像一个非常基本的眼睛的封闭形状。让我们开始吧:
- 我们总是需要从提取我们的画布元素开始,设置我们的宽度和高度变量,并定义一个弧度(因为我们发现它对我们有用):
var canvas = document.getElementById("eye");
var wid = canvas.width;
var hei = canvas.height;
var radian = Math.PI/180;
- 接下来,用纯色填充我们的画布,然后通过触发
beginPath
方法开始一个新形状:
var context = canvas.getContext("2d");
context.fillStyle = "#dfdfdf";
context.fillRect(0,0,wid,hei);
context.beginPath();
- 为我们的眼睛形状定义线宽和描边颜色:
context.lineWidth = 1;
context.strokeStyle = "#000000"; // line color
context.fillStyle = "#ffffff";
- 将我们的绘图指针移动到左中心点,因为我们需要在屏幕中心从左到右绘制一条线,然后再返回(只使用曲线):
context.moveTo(0,hei/2);
- 通过使用锚点从我们的初始点绘制两个二次曲线到画布的另一侧,然后返回到初始点,锚点位于画布区域的极端顶部和极端底部:
context.quadraticCurveTo(wid / 2, 0, wid,hei/2);
context.quadraticCurveTo(wid / 2, hei, 0,hei/2);
- 关闭路径。填充形状并在形状上使用
stroke
方法(fill
用于填充内容,stroke
用于轮廓):
context.closePath();
context.stroke();
context.fill();
干得好!您刚刚使用quadraticCurveTo
方法创建了您的第一个形状。
工作原理...
让我们仔细看看这个方法:
context.quadraticCurveTo(wid / 2, 0, wid,hei/2);
因为我们已经在原点(点 A)上,我们输入另外两个点——控制点和点 B。
context.quadraticCurveTo(controlX, controlY, pointB_X, pointB_Y);
在我们的示例中,我们创建了一个封闭形状——创建眼睛的起点。通过控制器来调整方向和曲线的大小。一个经验法则是,越靠近垂直线,曲线就会越平缓,而离中心点越远,曲线的形状就会越弯曲。
创建贝塞尔曲线
我们刚刚学到,使用二次曲线时我们有一个控制点。虽然我们可以用一个控制点做很多事情,但我们并没有真正对曲线有完全的控制。所以让我们更进一步,添加一个控制点。添加第二个控制点实际上增加了这两个点之间的关系,使其成为三个控制因素。如果我们包括实际的锚点(我们有两个),最终会有五个控制形状的点。这听起来很复杂;因为我们获得的控制越多,理解它的工作原理就越复杂。仅仅通过代码来弄清楚复杂的曲线并不容易,因此我们实际上使用其他工具来帮助我们找到正确的曲线。
为了证明前面的观点,我们可以找到一个非常复杂的形状并从那个形状开始(不用担心,在本示例中,我们将练习一个非常简单的形状,以便搞清楚概念)。我们将选择绘制加拿大国旗,主要是枫叶。
准备工作
这个示例很难理解,但我们将在接下来的工作原理...部分详细介绍。所以如果您对曲线不熟悉,我强烈建议您在实现之前从工作原理...部分开始学习。
如何做...
让我们创建加拿大国旗。让我们直接进入 JavaScript 代码:
- 创建画布和上下文:
var canvas = document.getElementById("canada");
var wid = canvas.width;
var hei = canvas.height;
var context = canvas.getContext("2d");
- 填充背景以匹配加拿大国旗的背景:
context.fillStyle="#FF0000";
context.fillRect(0,0,50,100);
context.fillRect(wid-50,0,50,100);
- 开始一个新路径并将指针移动到
84,19
:
context.beginPath();
context.moveTo(84,19);
- 绘制曲线和线条以创建枫叶:
context.bezierCurveTo(90,24,92,24,99,8);
context.bezierCurveTo(106,23,107,23,113,19);
context.bezierCurveTo(108,43,110,44,121,31);
context.bezierCurveTo(122,37,124,38,135,35);
context.bezierCurveTo(130,48,131,50,136,51);
context.bezierCurveTo(117,66,116,67,118,73);
context.bezierCurveTo(100,71,99,72,100,93);
context.lineTo(97,93);
context.bezierCurveTo(97,72,97,71,79,74);
context.bezierCurveTo(81,67,80,66,62,51);
context.bezierCurveTo(67,49,67,48,63,35);
context.bezierCurveTo(74,38,75,37,77,31);
context.bezierCurveTo(88,44,89,43,84,19);
- 关闭路径并填充形状:
context.closePath();
context.fill();
现在,你已经创建了加拿大国旗。我不知道你是否已经知道它是如何工作的,或者我们是如何得到我们放入曲线中的看似随机的数字的,但你已经创建了加拿大国旗!不要担心,我们将立即在下一节中解密曲线的魔力。
它是如何工作的……
在我们解释加拿大国旗的工作原理之前,我们应该退后一步,创建一个更简单的示例。在这个简短的示例中,我们将使用bezierCurveTo
方法创建一个椭圆形状。
context.moveTo(2,hei/2);
context.bezierCurveTo(0, 0,wid,0, wid-2,hei/2);
context.bezierCurveTo(wid, hei,0,hei, 2,hei/2);
context.closePath();
context.stroke();
context.fill();
就是这样。以下是你通过这种方法得到的结果:
如果你明白了这一点,那就太好了。我们现在将解释这是如何工作的,然后进入我们是如何找出加拿大国旗的所有点的。我们再次充分利用整个画布,并通过将两个控制器设置为画布的角来控制我们的控制器:
context.bezierCurveTo(controlPointX1, controlPointY1, controlPointX2, controlPointY2, pointBX, pointBY);
通过操纵控制器,看看使用两个点可以获得多少更多的控制权——当你需要更详细地控制曲线时,这是非常有用的。
这是我们完整国旗示例的核心。我强烈建议你探索改变控制点的值的影响,以更好地理解和敏感于它。现在是时候回到我们的国旗,看看我们是如何构造它的。
现在是时候将我们最复杂的绘图风格——贝塞尔曲线——用于比椭圆更有趣的东西了。我有一个坦白:当我决定从头开始创建加拿大国旗时,我感到害怕。我在想“我要怎么完成这个?这将花费我几个小时”,然后我恍然大悟……很明显,这面旗帜需要用很多贝塞尔点来创建,但我怎么知道这些点应该在哪里呢?因此,对于这样一个高级的形状,我打开了我的图形编辑器(在我这里是 Flash 编辑器),并为枫叶形状添加了枢轴点:
如果你仔细看前面的图表,你会发现我基本上是在加拿大国旗上做了标记,并在每个尖角上放了一个黑点。然后我创建了一个画布,并画了线,看看我得到的基本形状是否在正确的位置(顺便说一句,我得到这些点只是通过选择 Flash 中的点,看看它们的(x,y)坐标是否与画布坐标系统相同)。
var context = canvas.getContext("2d");
context.beginPath();
context.moveTo(84,19);
context.lineTo(99,8);
context.lineTo(113,19);
context.lineTo(121,31);
context.lineTo(135,35);
context.lineTo(136,51);
context.lineTo(118,73);
context.lineTo(100,93);
context.lineTo(97,93);
context.lineTo(79,74);
context.lineTo(62,51);
context.lineTo(63,35);
context.lineTo(77,31);
context.lineTo(84,19);
context.closePath();
context.stroke();
我得到了一个远离我想要的形状。但现在我知道我的形状正在朝着正确的方向发展。缺少的是连接点之间的曲线。如果你再次看前面的图表,你会注意到我在每个尖角之间放了两个蓝点,以定义曲线的位置以及它们的锐利或柔和程度。然后我回到画布,更新了值以获得这两个控制点。我添加了所有的曲线,并从创建描边切换到创建填充。
var context = canvas.getContext("2d");
context.fillStyle="#FF0000";
context.fillRect(0,0,50,100);
context.fillRect(wid-50,0,50,100);
context.beginPath();
context.moveTo(84,19);
context.bezierCurveTo(90,24,92,24,99,8);
context.bezierCurveTo(106,23,107,23,113,19);
context.bezierCurveTo(108,43,110,44,121,31);
context.bezierCurveTo(122,37,124,38,135,35);
context.bezierCurveTo(130,48,131,50,136,51);
context.bezierCurveTo(117,66,116,67,118,73);
context.bezierCurveTo(100,71,99,72,100,93);
context.lineTo(97,93);
context.bezierCurveTo(97,72,97,71,79,74);
context.bezierCurveTo(81,67,80,66,62,51);
context.bezierCurveTo(67,49,67,48,63,35);
context.bezierCurveTo(74,38,75,37,77,31);
context.bezierCurveTo(88,44,89,43,84,19);
context.closePath();
context.fill();
太棒了!我刚刚得到了一个几乎完美的国旗,我觉得这对这个样本来说已经足够了。
不要试图自己创建非常复杂的形状。也许有一些人可以做到,但对于我们其他人来说,最好的方法是通过某种视觉编辑器来追踪元素。然后我们可以获取图形信息,并像我在加拿大国旗示例中所做的那样更新画布中的值。
在这个阶段,我们已经涵盖了画布中可以涵盖的最复杂的形状。本章的其余部分专门讨论屏幕上内容的其他操作方式。
将图像集成到我们的艺术中
幸运的是,我们并不总是需要从头开始,我们可以把更复杂的艺术留给外部图像。让我们想想如何将图像集成到我们的画布中。
准备工作
在本章中,我们一直在讨论国旗主题,现在我觉得现在是时候再添一面国旗了。所以让我们把目光转向海地,让他们的国旗运行起来。要创建这面国旗,我们需要有放置在国旗中心的象征的图像。
在源文件中,您会找到一个中心图形的图像(在img/haiti.png
)。顺便说一句,当将艺术作品整合到画布中时,最好尽量避免通过代码调整图像大小,以保持图像质量。
如何做...
我们将准备背景以匹配国旗,然后将整个图像放在国旗的中心/画布上:
- 按照我们需要访问画布的基本步骤。设置宽度、高度和实际上下文:
var canvas = document.getElementById("haiti");
var wid = canvas.width;
var hei = canvas.height;
var context = canvas.getContext("2d");
- 绘制背景元素:
context.fillStyle="#00209F";
context.fillRect(0,0,wid,hei/2);
context.fillStyle="#D21034";
context.fillRect(0,hei/2,wid,hei/2);
- 创建一个新的
Image
对象:
var oIMG = new Image();
- 创建一个
onLoad
函数(当图像加载时将被调用):
oIMG.onload = function(){
context.drawImage(this, (wid-this.width)/2, (hei-this.height)/2);
};
- 设置图像的来源:
oIMG.src = "img/haiti.png";
是的,将图像添加到画布中是如此简单,但让我们更深入地审视一下我们刚刚做的事情。
它是如何工作的...
创建图像涉及下载其数据,然后以与画布相同的方式创建一个新的图像容器:
var oIMG = new Image();
下一步是创建一个监听器,当图像加载并准备好使用时将被触发:
oIMG.onload = theListenerFunctionHere;
加载过程的最后一步是告诉画布应该加载哪个图像。在我们的情况下,我们正在加载img/haiti.png
:
oIMG.src = "img/haiti.png";
加载图像并准备好使用它只是第一步。如果我们在没有实际告诉画布该怎么处理它的情况下运行我们的应用程序,除了加载图像之外什么也不会发生。
在我们的情况下,当我们的监听器被触发时,我们将图像按原样添加到屏幕的中央:
context.drawImage(this, (wid-this.width)/2, (hei-this.height)/2);
这就是将图像整合到画布项目中所需的全部步骤。
还有更多...
在画布中,我们可以对图像进行更多的操作,而不仅仅是将它们用作背景。我们可以精确定义图像的哪些部分(缩放)。我们可以调整和操作整个图像(缩放)。我们甚至可以对图像进行像素操作。我们可以对图像做很多事情,但在接下来的几个主题中,我们将涵盖一些更常用的操作。
缩放图像
我们可以通过向drawImage
函数添加两个参数来缩放图像,这两个参数设置了我们图像的宽度和高度。尝试以下操作:
context.drawImage(this, (wid-this.width)/2, (hei-this.height)/2 , 100, 120);
在前面的示例中,我们正在加载相同的图像,但我们正在强制调整大小的图像(请注意,位置不会在舞台的实际中心)。
添加更多的控制。
您可以控制图像的许多方面。如果您需要比前面示例更多的控制,您需要输入可能坐标的完整数量:
context.drawImage(this, sourceX, sourceY, sourceWidth, sourceHeight, destX, destY, destWidth, destHeight);
在这种情况下,顺序已经改变(注意!)。现在,在this
之后的前两个参数是图像的本地 x 和 y 坐标,然后是宽度和高度(创建我们谈论的裁剪),然后是画布上的位置及其控制信息(x、y、宽度和高度)。
在我们的情况下:
context.drawImage(this, 25,25,20,20,0,0,50,50);
前面的代码行意味着我们想要从图像的内部位置(25,25)取图像,并且我们想要从中裁剪出一个 20 x 20 的矩形。然后我们想要将这个新裁剪的图像定位在(0,0),也就是画布的左上角,我们希望输出是一个 50 x 50 的矩形。
使用图像作为填充
我们可以使用加载的图像来填充对象:
var oIMG = new Image();
oIMG.onload = function(){
var pattern = context.createPattern(this, "repeat");
createStar(context,wid/2,hei/2,20,50,20,pattern,"#ffffff",20);
};
oIMG.src = "img/haiti.png";
图像加载后(始终在图像加载后,您开始操作它),我们创建一个基于我们的图像重复的模式:
var pattern = context.createPattern(this, "repeat");
然后我们可以使用这种模式作为我们的填充。因此,在这种情况下,我们正在调用我们在早期任务中创建的createStar
——通过以下模式在屏幕中心绘制一个星星:
createStar(context,wid/2,hei/2,20,50,20,pattern,"#ffffff",20);
这结束了我们对旗帜的痴迷,转向了在旗帜中看不到的形状。顺便说一下,在这个阶段,你应该能够创建世界上所有的旗帜,并利用集成图像的优势,当你自己从头开始绘制它时,这样做就不再有趣,比如详细的国家标志。
用文本绘图
我同意,我们一直在做一些复杂的事情。现在,是时候放松一下,踢掉鞋子,做一些更容易的事情了。
准备工作
好消息是,如果你在这个页面上,你应该已经知道如何启动和运行画布的基础知识。所以除了选择文本的字体、大小和位置之外,你没有太多需要做的事情。
注意
在这里,我们不涉及如何嵌入在 JavaScript 中创建的字体,而是通过 CSS,我们将使用基本字体,并希望在这个示例中取得最好的效果。
如何做...
在这个例子中,我们将创建一个文本字段。在这个过程中,我们将第一次使用渐变和阴影。执行以下步骤:
- 获得对画布 2D API 的访问:
var canvas = document.getElementById("textCanvas");
var wid = canvas.width;
var hei = canvas.height;
var context = canvas.getContext("2d");
- 创建渐变样式并用它填充背景:
var grd = context.createLinearGradient(wid/2, hei/2, wid, hei);
grd.addColorStop(0, "#8ED6FF");
grd.addColorStop(1, "#004CB3")
context.fillStyle= grd;
context.fillRect(0,0,wid,hei);
- 创建用于文本的渐变:
grd = context.createLinearGradient(100, hei/2, 200, hei/2+110);
grd.addColorStop(0, "#ffff00");
grd.addColorStop(1, "#aaaa44");
- 定义要使用的字体并设置样式:
context.font = "50pt Verdana, sans-serif";
context.fillStyle = grd;
- 在绘制文本之前添加阴影细节:
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
context.shadowBlur = 8;
context.shadowColor = 'rgba(255, 255, 255, 0.5)';
- 使用
fillText
填充形状,使用strokeText
描绘形状的轮廓(请注意,我称文本为形状;这是因为一旦我们绘制它,它就只是我们画布的一部分,而不是实时文本)。
context.fillText("Hello World!", 100, hei/2);
context.strokeStyle = "#ffffff";
context.strokeText("Hello World!", 100, hei/2);
就是这样,我们刚刚将我们第一次绘制的文本集成到了画布中。
它是如何工作的...
到目前为止,我们一直在使用纯色。现在,我们将摆脱这一点,转向渐变颜色的新世界。请参考以下代码片段:
var grd = context.createLinearGradient(wid/2, hei/2, wid, hei);
grd.addColorStop(0, "#8ED6FF");
grd.addColorStop(1, "#004CB3");
创建渐变涉及几个步骤。第一步是定义它的范围:
var grd = context.createLinearGradient(x1, y1, x2, y2);
与许多其他语言相反,在画布中定义渐变的旋转和大小非常容易。如果你以前使用过 Photoshop,你会发现这很容易(即使你没有,它也会很容易)。
你需要做的就是定义渐变的起始位置和结束位置。你可以将两个点发送到createLinearGradient
方法中:
grd.addColorStop(0, "#8ED6FF");
grd.addColorStop(1, "#004CB3");
在这个过渡中,我们只使用两种颜色。将它们放在 0 和 1 之间的值。这些值是比率,换句话说,我们要求从渐变区域的开始一直到结束来扩展颜色过渡。我们可以添加更多的颜色,但我们的目标是将它们都绑定在 0 到 1 的比率内。你添加的颜色越多,你就需要更多地玩弄发送到第一个参数的值。
你刚刚完成了创建渐变。现在是时候使用它了:
context.fillStyle= grd;
context.fillRect(0,0,wid,hei);
在这部分中,我们将使用fillStyle
方法,然后创建一个矩形。
请注意,你可能发送到addColorStop
方法的值范围的重要性。随着你在渐变中添加更多的颜色,这里发送的值的重要性就会更加明显。这些点不是计数器,而是我们示例中颜色的比率。过渡是在两种颜色的范围从 0 到 1 之间,换句话说,它们从我们发送到createLinearGradient
方法的第一个点一直到最后一个点进行过渡。由于我们正在使用两种颜色,这对我们来说是完美的比率。
虽然我们没有涉及径向渐变,但对你来说应该很容易,因为我们已经学到了很多关于径向形状和渐变的知识。该方法的签名如下:
context.createRadialGradient(startX,startY,startR, endX,endY,endR);
这里唯一的区别是我们的形状是一个径向形状。我们还想将起始半径和结束半径添加到其中。你可能会想知道为什么我们需要两个甚至更多的半径。那么为什么我们不能根据两个点(起点和终点)之间的距离来确定半径呢?我希望你会对此感到好奇,如果你没有,那么在阅读下一段之前,请先思考一下。
我们可以单独控制半径,主要是为了使我们能够分离半径并使我们能够在不改变实际艺术或重新计算颜色比例的情况下移动绘图中的焦点。一个真正好的方法是在绘制月亮时使用它。月亮的渐变随时间会改变,或者更准确地说,颜色的半径和半径的位置会随时间改变,具体取决于月亮相对于太阳的位置。
我们还没有完成。我们刚刚掌握了关于渐变的所有知识,现在是时候将一些文本整合到其中了。
context.font = "50pt Verdana, sans-serif";
context.fillText("Hello World!", 100, hei/2);
我们设置全局字体值,然后创建一个新的文本元素。fillText
方法有三个参数;第一个是要使用的文本,另外两个是新元素的 x 和 y 位置。
context.strokeStyle = "#ffffff";
context.strokeText("Hello World!", 100, hei/2);
在我们的例子中,我们给我们的文本绘制了填充和轮廓。这两个函数是分开调用的。fillText
方法用于填充形状的内容,而strokeText
方法用于轮廓文本。我们可以使用其中一个或两个方法,它们可以获得完全相同的参数。
还有更多...
有一些更多的选项可以让你去探索。
在文本中使用渐变
如果您可以对画布中的任何图形元素进行任何操作,那么您也可以对文本进行操作,例如,在我们的示例中,我们为文本使用了渐变。
grd = context.createLinearGradient(100, hei/2, 200, hei/2+110);
grd.addColorStop(0, "#ffff00");
grd.addColorStop(1, "#aaaa44");
context.font = "50pt Verdana, sans-serif";
context.fillStyle = grd;
请注意,我们正在更新我们的渐变。我们上一个渐变对于如此小的文本区域来说太大了。因此,我们正在从文本的开始周围水平绘制一条线,长度为 110 像素。
添加阴影和发光
您可以向任何填充元素添加阴影/发光:
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
context.shadowBlur = 8;
context.shadowColor = 'rgba(255, 255, 255, 0.5)';
context.fillText("Hello World!", 100, hei/2);
您可以控制阴影的偏移位置。在我们的例子中,我们希望它成为一个发光的效果,所以我们把阴影放在了我们的元素正下方。当将模糊值设置为阴影时,尝试使用 2 的幂值以提高效率(渲染 2 的幂值更容易)。
请注意,当我们定义阴影颜色时,我们选择使用 RGBA,因为我们希望将 alpha 值设置为 50%。
理解像素操作
现在您已经掌握了在画布中绘制的技巧,是时候转向与画布一起工作的新方面了。在画布中,您可以操作像素。它不仅是一个矢量绘图工具,还是一个非常智能的像素编辑器(光栅)。
准备就绪
现在我们即将开始读取画布上存在的数据,我们需要了解在处理像素时安全性是如何工作的。为了保护不属于您的内容,与您的主机不同的数据的处理涉及安全问题。我们不会在本节中涵盖这些安全问题,并且将始终使用与我们的代码(或全部本地)在同一域中的图像。
您的第一步是找到您希望使用的图像(我已经将自己的旧图像添加到了源文件中)。在本示例中,我们将重新创建一个像素淡出动画-非常酷,对幻灯片非常有用。
如何做...
让我们让我们的代码运行起来,然后分解它看看它是如何工作的。执行以下步骤:
- 创建一些辅助全局变量:
var context;
var imageData;
var pixelData;
var pixelLen;
var currentLocation=0;
var fadeOutImageInterval;
- 创建一个
init
函数(在接下来的步骤中,所有代码都将在这个函数中):
function init(){
//all the rest of the code will go in here
}
- 为 2D 画布 API 创建一个上下文变量:
function init(){
var canvas = document.getElementById("textCanvas");
var wid = canvas.width;
var hei = canvas.height;
context = canvas.getContext("2d");
- 创建一个新图像:
var oIMG = new Image();
- 添加
onload
监听器逻辑:
oIMG.onload = function(){
context.drawImage(this, 0,0,this.width,this.height,0,0,wid,hei);
imageData = context.getImageData(0, 0, wid, hei);
pixelData = imageData.data;
pixelLen = pixelData.length;
fadeOutImageInterval = setInterval(fadeOutImage, 25);
};
- 定义图像源:
oIMG.src = "img/slide2.jpg";
} //end of init function
- 创建一个名为
fadeOutImage
的新函数。这个图像将过渡我们的图像:
function fadeOutImage(){
var pixelsChanged=0;
for (var i = 0; i < pixelLen; i +=4) {
if(pixelData[i]) {
pixelData[i] = pixelData[i]-1; // red
pixelsChanged++;
}
if(pixelData[i + 1]){
pixelData[i + 1] = pixelData[i+1]-1; // green
pixelsChanged++;
}
if(pixelData[i + 2]){
pixelData[i + 2] = pixelData[i+2]-1; // green
pixelsChanged++;
}
}
context.putImageData(imageData, 0, 0);
if(pixelsChanged==0){
clearInterval(fadeOutImageInterval);
alert("we are done fading out");
}
}
您的结果应该看起来像以下截图:
它是如何工作的...
我们将跳过解释我们在早期示例中已经涵盖的内容,比如如何加载图像以及如何使用drawImage
方法(在本章前面讨论的将图像整合到我们的艺术品中配方中涵盖)。
var context;
var imageData;
var pixelData;
var pixelLen;
var currentLocation=0;
var fadeOutImageInterval;
我们将在代码中看到这些变量的用法,但所有这些变量都已保存为全局变量,因此无需在函数中重新定义它们。通过一次性定义这些变量,我们提高了应用程序的效率。
真正的新逻辑始于onLoad
监听器。在我们将图像绘制到画布上后,我们添加了新的逻辑。在下面的代码片段中进行了突出显示:
var oIMG = new Image();
oIMG.onload = function(){
context.drawImage(this, 0,0,this.width,this.height,0,0,wid,hei);
imageData = context.getImageData(0, 0, wid, hei);
pixelData = imageData.data;
pixelLen = pixelData.length;
fadeOutImageInterval = setInterval(fadeOutImage, 25);
};
oIMG.src = "img/slide2.jpg";
我们现在开始利用在画布区域和全局存储信息的优势。我们存储的第一个变量是imageData
。这个变量包含了我们画布的所有信息。我们通过调用context.getImageData
方法来获取这个变量。
context.getImageData(x, y, width, height);
getImageData
函数返回矩形区域的每个像素。我们需要通过定义我们想要的区域来设置它。在我们的情况下,我们希望整个画布区域作为我们的图像设置。
返回的对象(imageData
)将像素数据信息直接存储在其数据属性(imageData.data
)中,这是我们直接处理像素时的主要关注点。该对象包含画布中每个像素的所有颜色信息。信息存储在四个单元格(红色、绿色、蓝色和 alpha 通道)中。换句话说,如果我们的应用程序中总共有 100 个像素,我们期望我们的数组在imageData.data
数组中包含 400 个单元格。
在我们的onLoad
监听器中完成逻辑之前,还剩下最后一件事要做,那就是触发我们的动画,使我们的图像过渡;为此,我们将添加一个间隔,如下所示:
fadeOutImageInterval = setInterval(fadeOutImage, 25);
我们的动画在每 25 毫秒触发一次,直到完成。淡出视图的逻辑发生在我们的fadeOutImage
函数中。
现在我们已经做好了所有的准备工作,是时候深入了解fadeoutImage
函数了。在这里,我们将进行实际的像素处理逻辑。该函数的第一步是创建一个变量,用于计算我们的imageData.data
数组所做的更改次数。当达到所需的更改次数时,我们终止我们的间隔(或在实际应用中可能是动画下一个图像):
var pixelsChanged=0;
现在我们开始通过使用for
循环遍历所有像素:
for (var i = 0; i < pixelLen; i +=4) {
//pixel level logic will go in here
}
每个像素存储 RGBA 值,因此每个像素在我们的数组中占据四个位置,因此我们每次跳过四个步骤以在像素之间移动。
context.putImageData(imageData, 0, 0);
当我们完成了对数据的操作,就该更新画布了。为此,我们只需要将新数据发送回我们的上下文。第二个和第三个参数是 x 和 y 的起始点。
if(pixelsChanged==0){
clearInterval(fadeOutImageInterval);
alert("we are done fading out");
}
当我们没有更多的更改时(您可以调整以符合您的愿望,例如当更改的像素少于 100 个时),我们终止间隔并触发警报。
在我们的for
循环中,我们将降低红色、绿色和蓝色的值,直到它们降至 0。在我们的情况下,由于我们正在计算更改,因此我们还将计数器添加到循环中:
for (var i = 0; i < pixelLen; i +=4) {
if(pixelData[i]) {
pixelData[i] = pixelData[i]-1; // red
pixelsChanged++;
}
if(pixelData[i + 1]){
pixelData[i + 1] = pixelData[i+1]-1; // green
pixelsChanged++;
if(pixelData[i + 2]){
pixelData[i + 2] = pixelData[i+2]-1; // blue
pixelsChanged++;
}
}
我们之前提到每个像素在数组中有四个单元格的信息。前三个单元格存储 RGB 值,而第四个存储 alpha 通道。因此,我认为值得注意的是,我们跳过位置i+3
,因为我们不希望影响 alpha 通道。pixelData
数组中的每个元素的值都在0
和255
之间。换句话说,如果该像素的值为#ffffff
(白色),所有三个 RGB 单元格的值将等于255
。顺便说一句,要使这些单元格中的值降至0
,需要调用我们的函数 255 次,因为单元格中的值将从255
开始,每次减 1。
我们总是跳过位置i+3
,因为我们不希望在我们的数组中改变任何内容。我们的值在255
和0
之间;换句话说,如果我们的图像的值为#ffffff
(完全白色像素),我们的函数将下降255
次才能达到0
。
使图像变为灰度
要使图像或画布变为灰度,我们需要考虑所有的颜色(红色、绿色、蓝色)并将它们混合在一起。混合在一起后,得到一个亮度值,然后我们可以将其应用到所有的像素上。让我们看看它的实际效果:
function grayScaleImage(){
for (var i = 0; i < pixelLen; i += 4) {
var brightness = 0.33 * pixelData[i] + 0.33 * pixelData[i + 1] + 0.34 * pixelData[i + 2];
pixelData[i] = brightness; // red
pixelData[i + 1] = brightness; // green
pixelData[i + 2] = brightness; // blue
}
context.putImageData(imageData, 0, 0);
}
在这种情况下,我们取红色(pixelData[i]
),绿色(pixelData[i+1]
)和蓝色(pixelData[i+2]
),并使用每种颜色的三分之一来组合在一起得到一种颜色,然后我们将它们全部赋予这个新的平均值。
尝试只改变三个值中的两个,看看会得到什么结果。
像素反转
颜色反转图像非常容易,因为我们只需要逐个像素地取最大可能值(255
)并从中减去当前值:
function colorReverseImage(){
for (var i = 0; i < pixelLen; i += 4) {
pixelData[i] = 255-pixelData[i];
pixelData[i + 1] = 255-pixelData[i+1];
pixelData[i + 2] = 255-pixelData[i+2];
}
context.putImageData(imageData, 0, 0);
}
就是这样!我们讨论了一些像素操作的选项,但限制实际上取决于你的想象力。实验一下,你永远不知道会得到什么结果!
第三章:创建基于笛卡尔的图表
在本章中,我们将涵盖以下主题:
-
从头开始构建条形图
-
在散点图中传播数据
-
构建线图
-
创建飞行砖图(瀑布图)
-
构建蜡烛图(股票图)
介绍
我们放大的第一个图表/图表是最受欢迎和最简单的创建。我们可以粗略地将它们分类为基于笛卡尔的图表。总的来说,这种图表风格相对简单;它为探索数据的惊人创造方式打开了大门。在本章中,我们将奠定构建图表的基础,希望能激励您提出自己的创意,以创建引人入胜的数据可视化。
从头开始构建条形图
最简单的图表是只包含一维数据的图表(每种类型只有一个值)。有许多方法可以展示这种类型的数据,但最受欢迎、逻辑和简单的方法是创建一个简单的条形图。即使在非常复杂的图表中,创建这个条形图所涉及的步骤也会非常相似。这种类型的图表的理想用法是当主要目标是展示简单数据时,如下所示:

准备好
创建一个包含画布和onLoad
事件的基本 HTML 文件,该事件将触发init
函数。加载03.01.bar.js
脚本。我们将按照以下的食谱创建 JavaScript 文件的内容:
<!DOCTYPE html>
<html>
<head>
<title>Bar Chart</title>
<meta charset="utf-8" />
<script src="img/03.01.bar.js"></script>
</head>
<body onLoad="init();" style="background:#fafafa">
<h1>How many cats do they have?</h1>
<canvas id="bar" width="550" height="400"> </canvas>
</body>
</html>
一般来说,创建图表有三个步骤:定义工作区域,定义数据源,然后在数据中绘制。
如何做...
在我们的第一个案例中,我们将比较一组朋友和他们各自拥有的猫的数量。我们将执行以下步骤:
- 定义你的数据集:
var data = [{label:"David",
value:3,
style:"rgba(241, 178, 225, 0.5)"},
{label:"Ben",
value:2,
style:"#B1DDF3"},
{label:"Oren",
value:9,
style:"#FFDE89"},
{label:"Barbera",
value:6,
style:"#E3675C"},
{label:"Belann",
value:10,
style:"#C2D985"}];
对于这个例子,我创建了一个可以包含无限数量元素的数组。每个元素包含三个值:标签、值和其填充颜色的样式。
- 定义你的图表轮廓。
现在我们有了数据源,是时候创建我们的基本画布信息了,我们在每个样本中都会创建:
var can = document.getElementById("bar");
var wid = can.width;
var hei = can.height;
var context = can.getContext("2d");
context.fillStyle = "#eeeeee";
context.strokeStyle = "#999999";
context.fillRect(0,0,wid,hei);
- 下一步是定义我们的图表轮廓:
var CHART_PADDING = 20;
context.font = "12pt Verdana, sans-serif";
context.fillStyle = "#999999";
context.moveTo(CHART_PADDING,CHART_PADDING);
context.lineTo(CHART_PADDING,hei-CHART_PADDING);
context.lineTo(wid-CHART_PADDING,hei-CHART_PADDING);
var stepSize = (hei - CHART_PADDING*2)/10;
for(var i=0; i<10; i++){
context.moveTo(CHART_PADDING, CHART_PADDING + i* stepSize);
context.lineTo(CHART_PADDING*1.3,CHART_PADDING + i* stepSize);
context.fillText(10-i, CHART_PADDING*1.5, CHART_PADDING + i* stepSize + 6);
}
context.stroke();
- 我们的下一个和最后一步是创建实际的数据条:
var elementWidth =(wid-CHART_PADDING*2)/ data.length;
context.textAlign = "center";
for(i=0; i<data.length; i++){
context.fillStyle = data[i].style;
context.fillRect(CHART_PADDING +elementWidth*i ,hei-CHART_PADDING - data[i].value*stepSize,elementWidth,data[i].value*stepSize);
context.fillStyle = "rgba(255, 255, 225, 0.8)";
context.fillText(data[i].label, CHART_PADDING +elementWidth*(i+.5), hei-CHART_PADDING*1.5);
}
就是这样。现在,如果你在浏览器中运行应用程序,你会发现一个条形图被渲染出来。
它是如何工作的...
我创建了一个名为CHART_PADDING
的变量,它在整个代码中都被用来帮助我定位元素(变量是大写的,因为我希望它是一个常量;所以这是为了提醒自己这不是应用程序生命周期中会改变的值)。
让我们从我们的轮廓区域开始深入研究我们创建的样本:
context.moveTo(CHART_PADDING,CHART_PADDING);
context.lineTo(CHART_PADDING,hei-CHART_PADDING);
context.lineTo(wid-CHART_PADDING,hei-CHART_PADDING);
在这些行中,我们正在创建我们数据的 L 形框架;这只是为了帮助和提供视觉辅助。
下一步是定义我们将用来在视觉上表示数值数据的步数。
var stepSize = (hei – CHART_PADDING*2)/10;
在我们的样本中,我们正在硬编码所有数据。因此,在步长中,我们正在找到我们图表的总高度(画布的高度减去顶部和底部的填充),然后我们将其除以将在以下for
循环中使用的步数:
for(var i=0; i<10; i++){
context.moveTo(CHART_PADDING, CHART_PADDING + i* stepSize);
context.lineTo(CHART_PADDING*1.3,CHART_PADDING + i* stepSize);
context.fillText(10-i, CHART_PADDING*1.5, CHART_PADDING + i* stepSize + 6);
}
我们循环 10 次,每次画一条短线。然后使用fillText
方法添加数字信息。
请注意,我们发送值10-i
。这个值对我们很有效,因为我们希望顶部值为 10。我们从图表的顶部开始;我们希望显示的值为 10,随着i
的值增加,我们希望我们的值在循环的每一步中向下移动时变小。
接下来,我们要定义每个条的宽度。在我们的情况下,我们希望条形相互接触,为了做到这一点,我们将利用可用的总空间,除以数据元素的数量。
var elementWidth =(wid-CHART_PADDING*2)/ data.length;
在这个阶段,我们已经准备好画条了,但在这之前,我们应该计算条的宽度。
然后我们循环遍历所有数据并创建条形图:
context.fillStyle = data[i].style;
context.fillRect(CHART_PADDING +elementWidth*i ,hei-CHART_PADDING - data[i].value*stepSize,elementWidth,data[i].value*stepSize);
context.fillStyle = "rgba(255, 255, 225, 0.8)";
context.fillText(data[i].label, CHART_PADDING +elementWidth*(i+.5), hei-CHART_PADDING*1.5);
请注意,每次循环运行时,我们都会两次重置样式。如果我们不这样做,我们将无法获得我们希望获得的颜色。然后我们将文本放在创建的条形图的中间。
context.textAlign = "center";
还有更多...
在我们的示例中,我们创建了一个不灵活的条形图,如果这是我们创建图表的方式,我们将需要每次从头开始重新创建它们。让我们重新审视我们的代码,并对其进行调整,使其更具重用性。
重新审视代码
尽管一切都按我们希望的方式工作,但如果我们玩弄数值,它就会停止工作。例如,如果我只想有五个步骤;如果我们回到我们的代码,我们会找到以下行:
var stepSize = (hei - CHART_PADDING*2)/10;
for(var i=0; i<10; i++){
我们可以对其进行调整,以处理五个步骤:
var stepSize = (hei - CHART_PADDING*2)5;
for(var i=0; i<5; i++){
我们很快就会发现我们的应用程序并没有按预期工作。
为了解决这个问题,让我们创建一个新函数,用于创建图表的轮廓。在这样做之前,让我们提取数据对象并创建一个将包含步骤的新对象。让我们将数据移动并以可访问的格式进行格式化:
var data = [...];
var chartYData = [{label:"10 cats", value:1},
{label:"5 cats", value:.5},
{label:"3 cats", value:.3}];
var range = {min:0, max:10};
var CHART_PADDING = 20;
var wid;
var hei;
function init(){
深入研究chartYData
对象,因为它使我们能够在没有定义间距规则的情况下放入尽可能多的步骤,并且范围对象将存储整个图形的最小值和最大值。在创建新函数之前,让我们将它们添加到我们的init
函数中(以粗体标记的更改)。
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
var context = can.getContext("2d");
context.fillStyle = "#eeeeee";
context.strokeStyle = "#999999";
context.fillRect(0,0,wid,hei);
context.font = "12pt Verdana, sans-serif";
context.fillStyle = "#999999";
context.moveTo(CHART_PADDING,CHART_PADDING);
context.lineTo(CHART_PADDING,hei-CHART_PADDING);
context.lineTo(wid-CHART_PADDING,hei-CHART_PADDING);
fillChart(context,chartYData);
createBars(context,data);
}
在此代码中,我们所做的就是将图表的创建和其条形分开为两个单独的函数。现在我们有了一个外部数据源,用于图表数据和内容,我们可以构建它们的逻辑。
使用 fillChart 函数
fillChart
函数的主要目标是创建图表的基础。我们正在整合我们的新stepData
对象信息,并根据其信息构建图表。
function fillChart(context, stepsData){
var steps = stepsData.length;
var startY = CHART_PADDING;
var endY = hei-CHART_PADDING;
var chartHeight = endY-startY;
var currentY;
var rangeLength = range.max-range.min;
for(var i=0; i<steps; i++){
currentY = startY + (1-(stepsData[i].value/rangeLength)) * chartHeight;
context.moveTo(CHART_PADDING, currentY );
context.lineTo(CHART_PADDING*1.3,currentY);
context.fillText(stepsData[i].label, CHART_PADDING*1.5, currentY+6);
}
context.stroke();
}
我们的更改并不多,但通过它们,我们使我们的函数比以前更加动态。这一次,我们基于stepsData
对象和基于它的范围长度来确定位置。
使用 createBars 函数
我们的下一步是重新访问createBars
区域并更新信息,以便可以使用外部对象动态创建它。
function createBars(context,data){
var elementWidth =(wid-CHART_PADDING*2)/ data.length;
var startY = CHART_PADDING;
var endY = hei-CHART_PADDING;
var chartHeight = endY-startY;
var rangeLength = range.max-range.min;
var stepSize = chartHeight/rangeLength;
context.textAlign = "center";
for(i=0; i<data.length; i++){
context.fillStyle = data[i].style;
context.fillRect(CHART_PADDING +elementWidth*i ,hei-CHART_PADDING - data[i].value*stepSize,elementWidth,data[i].value*stepSize);
context.fillStyle = "rgba(255, 255, 225, 0.8)";
context.fillText(data[i].label, CHART_PADDING +elementWidth*(i+.5), hei-CHART_PADDING*1.5);
}
}
这里几乎没有什么改变,除了在定位数据和提取硬编码值的方式上有一些改变。比较我们源代码中的两个示例,并找出它们之间的区别。
在散点图中传播数据
散点图是一种非常强大的图表,主要用于在比较两组数据时获得鸟瞰图。例如,比较英语课上的分数和数学课上的分数,以找到相关关系。这种视觉比较方式可以帮助发现意想不到的数据集之间的关系。
这在目标是以非常直观的方式显示大量细节时是理想的。
准备工作
如果您还没有机会浏览本章第一个食谱的逻辑,我建议您偷偷看一眼,因为我们将在此基础上进行大量工作,同时扩展并使其稍微复杂化,以容纳两组数据。
常规的 HTML 启动代码可以在代码包中找到,或者查看第一章,在画布中绘制形状,以获取有关创建 HTML 文档的更多信息。
我重新访问了上一个食谱的数据源,并修改为存储学生数学、英语和艺术考试成绩的三个变量。
var data = [{label:"David",
math:50,
english:80,
art:92,
style:"rgba(241, 178, 225, 0.5)"},
{label:"Ben",
math:80,
english:60,
art:43,
style:"#B1DDF3"},
{label:"Oren",
math:70,
english:20,
art:92,
style:"#FFDE89"},
{label:"Barbera",
math:90,
english:55,
art:81,
style:"#E3675C"},
{label:"Belann",
math:50,
english:50,
art:50,
style:"#C2D985"}];
请注意,这些数据是完全随机的,因此我们无法从数据本身中学到任何东西;但我们可以学到很多关于如何准备好我们的图表以用于真实数据。我们删除了value
属性,而是用math
、english
和art
属性替换它。
如何做...
让我们直接进入 JavaScript 文件和我们想要进行的更改:
- 定义
y
空间和x
空间。为此,我们将创建一个存储所需信息的辅助对象:
var chartInfo= { y:{min:40, max:100, steps:5,label:"math"},
x:{min:40, max:100, steps:4,label:"english"}
};
- 现在是时候设置我们的其他全局变量并启动我们的
init
函数了:
var CHART_PADDING = 30;
var wid;
var hei;
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
var context = can.getContext("2d");
context.fillStyle = "#eeeeee";
context.strokeStyle = "#999999";
context.fillRect(0,0,wid,hei);
context.font = "10pt Verdana, sans-serif";
context.fillStyle = "#999999";
context.moveTo(CHART_PADDING,CHART_PADDING);
context.lineTo(CHART_PADDING,hei-CHART_PADDING);
context.lineTo(wid-CHART_PADDING,hei-CHART_PADDING);
fillChart(context,chartInfo);
createDots(context,data);
}
这里没有太多新东西。主要的变化已经被突出显示。让我们继续创建我们的fillChart
和createDots
函数。
- 如果你之前做过我们的上一个示例,你可能会注意到前一个示例中的函数和这个函数之间有很多相似之处。我故意改变了创建事物的方式,只是为了让它们更有趣。现在我们也处理两个数据点,所以很多细节已经改变。让我们来回顾一下:
function fillChart(context, chartInfo){
var yData = chartInfo.y;
var steps = yData.steps;
var startY = CHART_PADDING;
var endY = hei-CHART_PADDING;
var chartHeight = endY-startY;
var currentY;
var rangeLength = yData.max-yData.min;
var stepSize = rangeLength/steps;
context.textAlign = "left";
for(var i=0; i<steps; i++){
currentY = startY + (i/steps) * chartHeight;
context.moveTo(wid-CHART_PADDING, currentY );
context.lineTo(CHART_PADDING,currentY);
context.fillText(yData.min+stepSize*(steps-i), 0, currentY+4);
}
currentY = startY + chartHeight;
context.moveTo(CHART_PADDING, currentY );
context.lineTo(CHART_PADDING/2,currentY);
context.fillText(yData.min, 0, currentY-3);
var xData = chartInfo.x;
steps = xData.steps;
var startX = CHART_PADDING;
var endX = wid-CHART_PADDING;
var chartWidth = endX-startX;
var currentX;
rangeLength = xData.max-xData.min;
stepSize = rangeLength/steps;
context.textAlign = "left";
for(var i=0; i<steps; i++){
currentX = startX + (i/steps) * chartWidth;
context.moveTo(currentX, startY );
context.lineTo(currentX,endY);
context.fillText(xData.min+stepSize*(i), currentX-6, endY+CHART_PADDING/2);
}
currentX = startX + chartWidth;
context.moveTo(currentX, startY );
context.lineTo(currentX,endY);
context.fillText(xData.max, currentX-3, endY+CHART_PADDING/2);
context.stroke();
}
当你审查这段代码时,你会注意到我们的逻辑几乎是重复两次。在第一个循环和第一批变量中,我们正在计算y
空间中每个元素的位置,然后在这个函数的后半部分,我们继续计算x
区域的布局。画布中的 y 轴从上到下增长(顶部较低,底部较高),因此我们需要计算整个图形的高度,然后减去该值以找到位置。
- 我们的最后一个函数是渲染数据点,为此我们创建
createDots
函数:
function createDots(context,data){
var yDataLabel = chartInfo.y.label;
var xDataLabel = chartInfo.x.label;
var yDataRange = chartInfo.y.max-chartInfo.y.min;
var xDataRange = chartInfo.x.max-chartInfo.x.min;
var chartHeight = hei- CHART_PADDING*2;
var chartWidth = wid- CHART_PADDING*2;
var yPos;
var xPos;
for(var i=0; i<data.length;i++){
xPos = CHART_PADDING + (data[i][xDataLabel]-chartInfo.x.min)/xDataRange * chartWidth;
yPos = (hei - CHART_PADDING) -(data[i][yDataLabel]-chartInfo.y.min)/yDataRange * chartHeight;
context.fillStyle = data[i].style;
context.fillRect(xPos-4 ,yPos-4,8,8);
}
}
在这里,我们正在为每个点找出相同的细节——y
位置和x
位置——然后绘制一个矩形。现在让我们测试我们的应用程序!
它是如何工作的...
我们首先创建一个新的chartInfo
对象:
var chartInfo= { y:{min:40, max:100, steps:5,label:"math"},
x:{min:40, max:100, steps:4,label:"english"}
};
这个非常简单的对象封装了定义我们的图表实际输出的规则。仔细看,你会发现我们设置了一个名为chartInfo
的对象,其中包含有关 y 轴和 x 轴的信息。我们有一个最小值(min
属性),最大值(max
属性),我们想在我们的图表中拥有的步数(steps
属性),并且我们定义了一个标签。
让我们深入了解fillChart
函数的工作方式。实质上,我们有两个数值;一个是屏幕上的实际空间,另一个是空间所代表的值。为了匹配这些值,我们需要知道我们的数据范围以及我们的视图范围,因此我们首先通过找到我们的startY
点和endY
点,然后计算这两个点之间的像素数量:
var startY = CHART_PADDING;
var endY = hei-CHART_PADDING;
var chartHeight = endY-startY;
当我们尝试弄清楚从chartInfo
对象中放置数据时,这些值将被使用。因为我们已经在谈论那个对象,让我们看看我们对它做了什么:
var yData = chartInfo.y;
var steps = yData.steps;
var rangeLength = yData.max-yData.min;
var stepSize = rangeLength/steps;
由于我们现在的重点是高度,我们正在深入研究y
属性,为了方便起见,我们将其称为yData
。现在我们专注于这个对象,是时候弄清楚这个值的实际数据范围(rangeLength
)了,这将是我们的转换器数字。换句话说,我们想要在点startY
和endY
之间的视觉空间中,根据范围将其定位。当我们这样做时,我们可以将任何数据转换为 0-1 之间的范围,然后将它们定位在动态可见的区域中。
最后但并非最不重要的,由于我们的新数据对象包含我们想要添加到图表中的步数,我们使用该数据来定义步长值。在这个示例中,它将是 12。我们得到这个值的方式是通过取我们的rangeLength
(100-40=60)值,然后除以steps
的数量(在我们的例子中是 5)。现在我们已经解决了关键的变量,是时候遍历数据并绘制我们的图表了:
var currentY;
context.textAlign = "left";
for(var i=0; i<steps; i++){
currentY = startY + (i/steps) * chartHeight;
context.moveTo(wid-CHART_PADDING, currentY );
context.lineTo(CHART_PADDING,currentY);
context.fillText(yData.min+stepSize*(steps-i), 0, currentY+4);
}
这就是魔法发生的地方。我们遍历步数,然后再次计算新的Y
位置。如果我们将其分解,我们会看到:
currentY = startY + (i/steps) * chartHeight;
我们从图表的起始位置(上部区域)开始,然后通过取当前i
位置并将其除以总可能步数(0/5、1/5、2/5 等)来添加步骤。在我们的演示中,它是 5,但它可以是任何值,并且应该插入到chartInfo
的步骤属性中。我们将返回的值乘以我们之前计算的图表的高度。
为了弥补我们从顶部开始的事实,我们需要颠倒我们放入文本字段的实际文本:
yData.min+stepSize*(steps-i)
这段代码使用了我们之前的变量并让它们发挥作用。我们首先取可能的最小值,然后加上stepSize
乘以总步数减去当前步数的数量。
让我们深入了解createDots
函数以及它是如何工作的。我们从设置变量开始:
var yDataLabel = chartInfo.y.label;
var xDataLabel = chartInfo.x.label;
这是我最喜欢的配方之一。我们从chartInfo
对象中获取标签并将其用作我们的 ID;这个 ID 将用于从我们的数据对象中获取信息。如果您希望更改值,您只需要在chartInfo
对象中切换标签。
再次,我们需要弄清楚我们的范围,就像我们在fillChart
函数中所做的那样。这一次,我们想要获取 x 轴和 y 轴的实际范围以及我们需要处理的区域的实际宽度和高度:
var yDataRange = chartInfo.y.max-chartInfo.y.min;
var xDataRange = chartInfo.x.max-chartInfo.x.min;
var chartHeight = hei- CHART_PADDING*2;
var chartWidth = wid- CHART_PADDING*2;
我们还需要获取一些变量来帮助我们在循环中跟踪我们当前的x
和y
位置:
var yPos;
var xPos;
让我们深入到我们的循环中,主要是突出显示的代码片段:
for(var i=0; i<data.length;i++){
xPos = CHART_PADDING + (data[i][xDataLabel]-chartInfo.x.min)/xDataRange * chartWidth;
yPos = (hei - CHART_PADDING) -(data[i][yDataLabel]-chartInfo.y.min)/yDataRange * chartHeight;
context.fillStyle = data[i].style;
context.fillRect(xPos-4 ,yPos-4,8,8);
}
这里的一切的核心是发现我们的元素需要在哪里。逻辑对于xPos
和yPos
变量几乎是相同的,只有一些变化。我们需要做的第一件事是计算xPos
变量:
(data[i][xDataLabel]-chartInfo.x.min)
在这部分中,我们使用了我们之前创建的标签xDataLabel
来获取该科目中当前学生的分数。然后我们从中减去最低可能的分数。由于我们的图表不是从 0 开始的,我们不希望 0 和我们的最小值之间的值影响屏幕上的位置。例如,假设我们专注于数学,我们的学生得了 80 分;我们从中减去 40(80-40=40),然后应用以下公式:
(data[i][xDataLabel] - chartInfo.x.min) / xDataRange
我们将该值除以我们的数据范围;在我们的情况下,那将是(100-40)/60。返回的结果将始终在 0 和 1 之间。我们可以使用返回的数字并将其乘以像素的实际空间,以确切地知道在屏幕上定位我们的元素。我们通过将我们得到的值(在 0 和 1 之间)乘以总可用空间(在这种情况下是宽度)来这样做。一旦我们知道它需要定位的位置,我们就在图表上添加起始点(填充):
xPos = CHART_PADDING + (data[i][xDataLabel]-chartInfo.x.min)/xDataRange * chartWidth;
yPos
变量的逻辑与xPos
变量的逻辑相同,但这里我们只关注高度。
构建折线图
折线图是基于散点图的。与显示两个变量之间孤立相关性的散点图相反,折线图以多种方式讲述了一个故事;我们可以回到我们之前的散点图的配方,在散点图中传播数据,并在点之间画一条线来创建连接。这种类型的图表通常用于网站统计,随时间跟踪事物,速度,年龄等。让我们立即跳进去看看它的运作。
准备就绪
像往常一样,准备好您的 HTML 包装器。在这个配方中,我们实际上将根据之前的配方在散点图中传播数据来进行更改。
在这个案例研究中,我们将创建一个图表,显示 2011 年和 2010 年有多少新成员加入了我的网站02Geek.com。我逐月收集了信息并将其汇总到两个数组中:
var a2011 = [38,65,85,111,131,160,187,180,205,146,64,212];
var a2010 = [212,146,205,180,187,131,291,42,98,61,74,69];
两个数组的长度都是 12(代表一年的 12 个月)。我故意创建了一个与我们之前使用的完全不同的新数据源。我这样做是为了使我们以前的地图在这个例子中变得无用。我这样做是为了为这个配方增加一些额外的价值(一个很好的课程,教你如何操纵数据以适应,即使它不适合,而不是重建事物)。
var chartInfo= { y:{min:0, max:300, steps:5,label:"users"},
x:{min:1, max:12, steps:11,label:"months"}
};
对于我们的图表信息,我们正在使用相同的对象类型,并且对于y
位置,我们将假定范围从 0 到 300(因为我还没有在一个月内拥有超过 300 名成员的特权,但我对此抱有希望)。对于我们的x
位置,我们将其设置为输出值从 1 到 12(代表一年的 12 个月)。
好的,是时候来构建它了!
如何做...
和往常一样,我们的init
函数将看起来与我们在上一个示例中使用的函数非常相似。让我们看看在这个示例中发生了哪些修改:
- 更新/创建全局变量:
var a2011 = [38,65,85,111,131,160,187,180,205,146,64,212];
var a2010 = [212,146,205,180,187,131,291,42,98,61,74,69];
var chartInfo= { y:{min:0, max:300, steps:5,label:"users"},
x:{min:1, max:12, steps:11,label:"months"}
};
var CHART_PADDING = 20;
var wid;
var hei;
- 更新
init
函数:
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
var context = can.getContext("2d");
context.fillStyle = "#eeeeee";
context.strokeStyle = "#999999";
context.fillRect(0,0,wid,hei);
context.font = "10pt Verdana, sans-serif";
context.fillStyle = "#999999";
context.moveTo(CHART_PADDING,CHART_PADDING);
context.rect(CHART_PADDING,CHART_PADDING,wid-CHART_PADDING*2,hei-CHART_PADDING*2);
context.stroke();
context.strokeStyle = "#cccccc";
fillChart(context,chartInfo);
addLine(context,formatData(a2011, "/2011","#B1DDF3"),"#B1DDF3");
addLine(context,formatData(a2010, "/2010","#FFDE89"),"#FFDE89");
}
- 更改函数
createDots
的名称为addLine
并更新逻辑:
function addLine(context,data,style){
var yDataLabel = chartInfo.y.label;
var xDataLabel = chartInfo.x.label;
var yDataRange = chartInfo.y.max-chartInfo.y.min;
var xDataRange = chartInfo.x.max-chartInfo.x.min;
var chartHeight = hei- CHART_PADDING*2;
var chartWidth = wid- CHART_PADDING*2;
var yPos;
var xPos;
context.strokeStyle = style;
context.beginPath();
context.lineWidth = 3;
for(var i=0; i<data.length;i++){
xPos = CHART_PADDING + (data[i][xDataLabel]-chartInfo.x.min)/xDataRange * chartWidth;
yPos = (hei - CHART_PADDING) -(data[i][yDataLabel]-chartInfo.y.min)/yDataRange * chartHeight;
context.fillStyle = data[i].style;
context.fillRect(xPos-4 ,yPos-4,8,8);
i ? context.lineTo(xPos,yPos):context.moveTo(xPos,yPos);
}
context.stroke();
}
- 创建
formatData
函数:
function formatData(data , labelCopy , style){
newData = [];
for(var i=0; i<data.length;i++){
newData.push({ label:(i+1)+labelCopy,
users:data[i],
months:i+1,
style:style
});
}
return newData;
}
就是这样!我们完成了!
它是如何工作的...
我已经为我们的绘图工具集添加了一个新方法rect
;到目前为止,我们一直使用drawRect
方法。我使用rect
方法是因为它只是添加轮廓而不绘制任何东西,所以我可以分别执行描边或填充功能,并创建轮廓而不是填充。
fillChart
函数一点都没有改变,很酷,对吧?我将函数createDots
重命名为addLine
,因为对于我们的示例来说,这似乎更合适。该函数已经进行了一些添加,并且正在使用一个新函数formatData
来格式化数据以适应addLine
函数所期望的格式。
正如您可能已经注意到的,我们对我们的代码进行了一些小的更改,以适应这种图表样式的需求。让我们深入研究并看到它们在实际操作中的表现:
addLine(context,formatData(a2011,"/2011","#B1DDF3"),"#B1DDF3")
我们在调用addLine
函数的方式中可以明显看到的最大变化是,我们正在调用formatData
函数为我们呈现一个数据源,该数据源将被addLine
函数接受。您可能现在正在想,为什么我不只是创建需要为addLine
函数工作的数据的方式。当我们转移到真实的实时数据源时,我们经常会发现数据源与我们的原始工作不匹配。这并不意味着我们需要改变我们的工作,通常更好的解决方案是创建一个转换器方法,该方法将修改数据并重建它以匹配我们的应用程序结构,使其符合我们的期望格式。
从我们以前的示例中提醒一下:这是我们的数据源的样子:
var data = [{label:"David",
math:50,
english:80,
art:92
style:"rgba(241, 178, 225, 0.5)"},
...
];
尽管我们的数组目前是平的,但我们需要改变它以适应我们当前的系统;它期望两个属性来定义x
和y
值:
var chartInfo= { y:{min:0, max:300, steps:5,label:"users"},
x:{min:1, max:12, steps:11,label:"months"}
换句话说,我们需要创建的对象需要看起来像下面这样:
var data = [{label: "01/2011",
users:200,
months:1,
style:"#ff0000"} ... ];
所以让我们创建一个将创建这种数据格式的函数:
function formatData(data , labelCopy , style){
newData = [];
for(var i=0; i<data.length;i++){
newData.push({ label:(i+1)+labelCopy,
users:data[i],
months:i+1,
style:style
});
}
return newData;
}
请注意我们如何循环遍历我们的旧数组,并重新构造它以适应我们期望的数据格式,使用数组数据和发送到我们的formatData
函数的外部数据。即使在这个示例中我们没有使用所有的信息,我也想保持它与所有基本信息保持最新,以防您想要扩展这个示例。我们将在未来这样做。
提示
这是编程工具集中最强大的技巧之一。我遇到过许多开发人员,他们改变他们的代码而不是改变他们的数据以适应所需的应用程序结构。总是有一种方法可以修改数据,使其更容易被应用程序消耗,动态修改数据比更改架构要容易得多。
我没有改变这个addLine
函数的核心逻辑,而是只是从一个点到下一个点添加了画线。
如果您不熟悉三元操作符,它是一个简写的if
语句:
condition ? ifStatement: elseStatement;
顺便说一句,如果您担心效率问题,您可能希望通过将第一个实例提取出循环来更改for
循环,因为这是我们的三元运算符触发 else 值的唯一发生的地方。
还有更多...
让我们重新审视我们的代码,并优化它以使其更具适应性。我们的目标是为我们的图表添加更多的灵活性,以便以各种模式呈现。
我的目标是使我们的图表能够以三种渲染模式呈现:点模式(如前一个示例中),线模式(在本示例中),以及填充模式(新添加):
尽管在前面的屏幕截图中,我们有三个图表元素,它们都有填充,但在新代码中,您可以选择每行添加的方式。所以让我们开始吧。
启用在点和线之间切换模式
我们添加到函数中的所有工作都不需要经过大修,因为直到实际呈现之前,什么都看不见。这在一个地方受控,即我们在addLine
函数中创建描边的地方。因此,让我们添加一个新规则,如果没有发送样式,那就意味着我们不想创建一条线:
if(style)context.stroke();
换句话说,只有当我们有样式信息时,我们才会绘制刚刚创建的线;如果没有,就不会绘制线。
创建填充形状
为了创建填充形状并且为了保持我们的代码简洁,我们将在我们的代码中创建一个if...else
语句,如果用户发送了一个新的第四个参数,我们将以填充模式呈现它(更改在以下代码片段中突出显示):
function addLine(context,data,style,isFill){
var yDataLabel = chartInfo.y.label;
var xDataLabel = chartInfo.x.label;
var yDataRange = chartInfo.y.max-chartInfo.y.min;
var xDataRange = chartInfo.x.max-chartInfo.x.min;
var chartHeight = hei- CHART_PADDING*2;
var chartWidth = wid- CHART_PADDING*2;
var yPos;
var xPos;
context.strokeStyle = style;
context.beginPath();
context.lineWidth = 3;
if(!isFill){
for(var i=0; i<data.length;i++){
xPos = CHART_PADDING + (data[i][xDataLabel]-chartInfo.x.min)/xDataRange * chartWidth;
yPos = (hei - CHART_PADDING) -(data[i][yDataLabel]-chartInfo.y.min)/yDataRange * chartHeight;
context.fillStyle = data[i].style;
context.fillRect(xPos-4 ,yPos-4,8,8);
i==0? context.moveTo(xPos,yPos):context.lineTo(xPos,yPos);
}
if(style)context.stroke();
}else{
context.fillStyle = style;
context.globalAlpha = .6;
context.moveTo(CHART_PADDING,hei - CHART_PADDING)
for(var i=0; i<data.length;i++){
xPos = CHART_PADDING + (data[i][xDataLabel]-chartInfo.x.min)/xDataRange * chartWidth;
yPos = (hei - CHART_PADDING) -(data[i][yDataLabel]-chartInfo.y.min)/yDataRange * chartHeight;
context.lineTo(xPos,yPos);
}
context.lineTo( CHART_PADDING + chartWidth, CHART_PADDING+chartHeight);
context.closePath();
context.fill();
context.globalAlpha = 1;
}
}
新代码中的差异并不大。我们只是删除了一些代码,并添加了一些新行来创建一个完整的形状。我也叠加了 Alpha 值。一个更聪明的方法是重新审视发送的值,并根据需要放入 Alpha 值;但这留给你来改进。现在我们的addLine
函数可以添加三种类型的可视化,我们可以同时向我们的图表中添加多种类型(查看源代码以查看其运行情况)。
创建飞行砖块图(瀑布图)
在本章的每个配方中,我们都在提高我们代码的复杂性,因此我们将重新审视条形图,并使其适应我们不断发展的图表平台。完成这个小任务后,我们将准备好创建我们的第一个瀑布图,摆脱标准图表,进入更有创意的领域。
瀑布图是一个非常有用的图表,可以概述趋势,比如每月的总变化(正面和负面),同时概述整体价值。这种类型的图表有助于概述公司的总资产,同时显示他们在整个月份内是盈利还是亏损。这种类型的图表非常适合在正负值之间变化的数据。
准备工作
我们将利用我们在早期配方中创建的接口,因此我们将把条形图的创建整合到我们更新的函数库中。为此,我们需要找出我们在03.02.bar-revamp.js
中创建的旧createBars
函数。
在实施更改之前的代码如下:
function createBars(context,data){
var elementWidth =(wid-CHART_PADDING*2)/ data.length;
var startY = CHART_PADDING;
var endY = hei-CHART_PADDING;
var chartHeight = endY-startY;
var rangeLength = range.max-range.min;
var stepSize = chartHeight/rangeLength;
context.textAlign = "center";
for(i=0; i<data.length; i++){
context.fillStyle = data[i].style;
context.fillRect(CHART_PADDING +elementWidth*i ,hei-CHART_PADDING - data[i].value*stepSize,elementWidth,data[i].value*stepSize);
context.fillStyle = "rgba(255, 255, 225, 0.8)";
context.fillText(data[i].label, CHART_PADDING +elementWidth*(i+.5), hei-CHART_PADDING*1.5);
}
}
以下是我们新更新的函数,以适应我们在早期配方中开发的新技能(更改在以下代码片段中突出显示)。
function createBars(context,data){
var range = chartInfo.x;
var elementWidth =(wid-CHART_PADDING*2)/ data.length;
var startY = CHART_PADDING;
var endY = hei-CHART_PADDING;
var chartHeight = endY-startY;
var stepSize = chartHeight/(chartInfo.y.max-chartInfo.y.min);
context.textAlign = "center";
for(i=0; i<data.length; i++){
context.fillStyle = data[i].style;
context.fillRect(CHART_PADDING +elementWidth*i ,endY - data[i][chartInfo.y.label]*stepSize,elementWidth,data[i][chartInfo.y.label]*stepSize);
context.fillStyle = "rgba(255, 255, 225, 0.8)";
context.fillText(data[i].label, CHART_PADDING +elementWidth*(i+.5), hei-CHART_PADDING*1.5);
}
}
这些更改不仅仅是表面的;我们正在利用我们在之前的一些示例中使用的外部数据源。现在我们的函数已经更新并且在前两个配方中开发的最新逻辑中运行,现在是时候开始构建我们的瀑布图了。
如何做...
创建瀑布图的第一步是复制、粘贴和重命名函数createBars
,然后操纵它并改变数据呈现的方式(主要是元素的位置和方式)。在深入讨论之前,注意我们在这个方法中所做的更改:
- 让我们从一个更新后的数据源开始:
var a2011 = [60,60,60,111,-31,-80,0,-43,-29,14,64,12];
var chartInfo= { y:{min:0, max:300, steps:5,label:"users"},
x:{min:1, max:12, steps:11,label:"months"}
};
var CHART_PADDING = 20;
var wid;
var hei;
- 在
init
函数中,我们将更新以下突出显示的代码片段:
function init(){
...
context.strokeStyle = "#cccccc";
fillChart(context,chartInfo);
createWaterfall(context,formatData(a2011));
}
- 添加一些辅助变量:
function createWaterfall(context,data){
var range = chartInfo.x;
var elementWidth =(wid-CHART_PADDING*2)/ data.length;
var startY = CHART_PADDING;
var endY = hei-CHART_PADDING;
var chartHeight = endY-startY;
var stepSize = chartHeight/(chartInfo.y.max-chartInfo.y.min);
var currentY= endY;
var elementValue ;
var total=0;
context.textAlign = "center";
- 在
for
循环逻辑中,如果值不是0
,则绘制一个矩形:
for(i=0; i<data.length; i++){
elementValue = data[i][chartInfo.y.label];
total +=elementValue;
if(elementValue!=0){
context.fillStyle = elementValue>0? "#C2D985" :"#E3675C" ;
currentY -=(elementValue*stepSize);
context.fillRect(CHART_PADDING +elementWidth*i ,currentY,elementWidth,elementValue*stepSize);
}
- 如果当前数据值是
0
,那么将其作为总列:
else{
context.fillStyle = "#B1DDF3" ;
context.fillRect(CHART_PADDING +elementWidth*i ,currentY,elementWidth,endY-currentY);
elementValue = total; //hack so we see the right value
}
context.fillStyle = "rgba(255, 255, 255, .8)"
;
- 在元素内添加更改的值:
context.fillText(elementValue, CHART_PADDING +elementWidth*(i+.5), endY - (stepSize*total) + (stepSize*elementValue/2) + 6);
}
}
这里有很多变化,但实质上这两个函数几乎做了相同的事情;只有我们的瀑布图更智能、更详细。
它是如何工作的...
当我们开始考虑如何创建瀑布图时,第一步和第一个问题是每个元素之间存在关系。为了简化逻辑,我们希望创建一个计数器来存储变化(当前汇总值)。
var elementValue ;
var total=0;
第一个变量只是一个辅助变量,试图使我们的代码更易读,而总和则是为了跟踪当前总和是多少。
现在是时候进入for
循环并看到重大的变化。让我们首先关注我们可能需要在瀑布图中进行的任务类型。有三种类型(值上升、值下降和值保持不变)。在我们开始解决这些情况之前,让我们更新我们的变量:
for(i=0; i<data.length; i++){
elementValue = data[i][chartInfo.y.label];
total +=elementValue;
元素值将给我们当前的数值(不要忘记这与屏幕上的大小不对应;当我们想要绘制元素时,我们仍然需要将其乘以stepSize
数)。total
变量也是如此;我们只是跟踪当前的汇总。
因此,正如我们之前所述,我们有三个可能的任务,创建一个if...else
情况没有问题,如下所示:
if(elementValue>0){
//do the positive values
}else if(elementValue<0){
//do the negative values
}else{
//do 0
}
这将捕捉所有三种选项,但会添加一些不必要的额外代码。因此,我们将对负值和正值使用相同的if
语句,因为它们的逻辑非常接近。这样我们可以重用我们的代码并减少输入。
if(elementValue!=0){
//do positive/negative values
}else{
// do 0
}
完美!现在让我们深入了解正负任务:
context.fillStyle = elementValue>0? "#C2D985" :"#E3675C" ;
currentY -=(elementValue*stepSize);
context.fillRect(CHART_PADDING +elementWidth*i ,currentY,elementWidth,elementValue*stepSize);
请注意,这个代码块中的第一行代码是正负值之间唯一的区别。我们只是基于是否处于正负范围内改变颜色。在确定了currentY
位置之后,我们在当前位置创建一个矩形(这个当前位置是在值被添加之后,所以这是我们的终点)。最重要的元素是第四个参数,elementValue*stepSize
。第四个参数捕捉了矩形的大小。它捕捉了它是负值还是正值。elementValue
变量可以是正数或负数。这就是我们在这里使用的技巧,因为我们会向上绘制条(如果值为负),或向下绘制条(如果值为正)。如果我们首先创建绘图再更新我们的currentY
位置,那将会更加困难,可能需要我们创建三个单独的if
情况。像这样的情况确实是我觉得编程如此有趣的原因;找到利用相同代码做相反事情的隐藏方法。
现在是时候访问else
情况了:
}else{
context.fillStyle = "#B1DDF3" ;
context.fillRect(CHART_PADDING +elementWidth*i ,currentY,elementWidth,endY-currentY);
elementValue = total; //hack so we see the right value
}
在else
情况下,我们想要绘制条的全长,然后进行一个小技巧。我们将当前总和的值赋给elementValue
变量(这不会改变我们的原始数据,因为我们在没有再使用elementValue
变量之后才这样做)。我们这样做是为了避免在将文本添加到条中时再使用if...else
语句。只有当值为0
时,我们希望显示总和而不是当前变化,这就是这个技巧的作用。
留下我们创建瀑布图的最后一部分,即获取我们刚刚创建的元素中心的条形的值:
context.fillStyle = "rgba(255, 255, 255, .8)";
context.fillText(elementValue, CHART_PADDING +elementWidth*(i+.5), endY - (stepSize*total) + (stepSize*elementValue/2) + 6);
深入研究文本元素的定位;直到我弄清楚为止,我进行了一些调整。我在这里主要是在最后一个参数(我们新文本的y
位置)中进行操作,我取了我们图表的底部区域,减去当前的总数,这样就能得到条形图的顶端。但对于正值来说,这样做不太好,因为它会在条形图的上方,而对于负值来说,它会放在条形图的底部区域。这就需要创造性思维;我们可以把文本放在我们元素的中间。为了做到这一点,我们可以再次利用我们的elementValue
变量(因为它是正数或负数),如果我们取它的一半大小并加到我们的总数中,我们就会处于条形图的中心,只需要最后一个调整,给我们的值加上 6(因为我们的文本高度为 12 像素)。
就是这样!你刚刚创建了你的第一个瀑布图。让我们来测试一下;从我们的init
函数中删除任何数据可视化函数调用(比如createBars
或addLine
),并用我们的新函数替换它们:
createWaterfall(context,formatData(a2011));
注意
请注意,我正在使用formatData
对象,因为我只是重用了我们之前样本中的数组。我只是更新了值,使它们不超出总数 300:
var a2011 = [60,60,60,111,-31,-80,0,-43,-29,14,64,12];
还有更多……
我们结束的地方,引出了一个问题,那就是我们无法控制数据,而且我们要求最终用户/开发人员做出调整的越多,学习曲线就越长。我们有一个存储大部分辅助信息的chartInfo
对象,这很好,但是如果有人没有填写属性会怎么样呢?我们的应用程序应该失败,还是应该尽力为用户找到新的默认值?比如在这个例子中,用户没有填写y
对象的max
和min
属性:
var chartInfo= { y:{steps:5,label:"users"},
x:{min:1, max:12, steps:11,label:"months"}
};
用户指定他们想要多少步骤,但他们没有提供关于图表应该输出的最小值和最大值的任何信息。为了解决这个问题,我们需要重新审视我们创建图表的方式。到目前为止,我们完全分开地创建了图表(在我们的init
函数的最后两行):
fillChart(context,chartInfo);
createWaterfall(context,formatData(a2011));
首先我们通常创建背景,然后绘制项目,但在这种情况下,我们在fillchart
函数和createWaterfall
函数之间有一个更清晰的关系。由于我们试图减少用户的代码占用,我们不希望为每个样本添加大量逻辑,这些逻辑对于每种条形图类型都是独特的。因此,我们将重新审视我们创建的所有图形函数(addLine
、createBars
和createWaterfall
),并将fillChart
函数调用移到函数的第一件事。这将使我们能够在调用fillChart
函数之前创建自定义调整,这些调整对我们函数的最终用户(比如几个月后的你)是不可见的,因此你不需要记住一切是如何工作的。现在一切应该都能正常工作,但只有我们的createWaterfall
函数会知道如何处理缺失的信息(我会让你更新其他函数)。
function createWaterfall(context,data){
fillChart(context,chartInfo);
//all the rest the same
//do to all 3 functions
现在我们有了fillChart
函数,并且一切都正常工作,让我们在调用fillChart
函数之前添加一些额外的逻辑,以帮助动态添加min
/max
值:
function createWaterfall(context,data){
if(!chartInfo.y.min || !chartInfo.y.max)
updateCumulativeChartInfo(chartInfo,data);
fillChart(context,chartInfo);
请注意,我们正在检查min
或max
值是否缺失,如果是,我们将调用updateCumulativeChartInfo
函数来更新或添加这些值。
让我们创建updateCumulativeChartInfo
函数:
function updateCumulativeChartInfo(chartInfo,data){
var aTotal=[];
var total = 0;
aTotal.push(total);
for(i=0; i<data.length; i++){
total +=data[i][chartInfo.y.label]
aTotal.push(total);
}
chartInfo.y.min = Math.min.apply(this,aTotal);
chartInfo.y.max = Math.max.apply(this,aTotal);
}
我们使用两个变量:aTotal
和total
。aTotal
变量在每个循环中存储总数。在aTotal
数组中,我们有了我们的total
变量在图表的所有阶段中的值后,就该确定最小值和最大值了。我们有一个问题。Math.min
方法可以接受无限数量的参数,但我们有一个与Math.min
方法的要求不兼容的数组。为了确定值,我们可以使用apply
方法的一个有趣的技巧。每个函数都有一个apply
方法。apply
方法的作用是使您能够更改函数的作用域并将参数作为数组发送。
注意
有关apply
方法的更多信息,请查看以下网站的视频:
02geek.com/catagory/favorites/apply.html
现在我们的数据已经动态创建,一切应该正常工作。当我们运行应用程序时,我们会发现得到一些太详细的数字(例如 3.33333)。下一步是进行一些格式调整。
清理数字的格式
为了解决我们的数字值非常丑陋的问题,我们可以创建一个格式化函数,并在每次输出动态创建的值时调用它。所以让我们首先创建这个函数:
function formatNumber(num,lead){
for(var i=0;i<lead;i++) num*=10;
num = parseInt(num);
for(var i=0;i<lead;i++) num/=10;
return num;
}
函数参数是要格式化的值(num
)和小数点后我们想要的位数。在函数中,我们将值乘以十;乘以的次数基于lead
变量的值。然后我们将数字转换为整数,再次除以该数字。
最后但并非最不重要的是,让我们跟踪我们添加文本的位置;我们会在fillChart
函数中找到它。我们唯一剩下的事情就是找到受影响的正确文本,并更新它以使用我们的新格式化函数:
context.fillText(formatNumber(yData.min+stepSize*(steps-i),2), 0, currentY+4);
我们的格式看起来会好得多。是的,你可能应该将这些细节留给外部chartInfo
对象来配置,但我们会让你来使我们的库更加智能。
我留下的其他任务
我们的新瀑布图有一个假设,即我们总是从零开始。在我们的示例中,我们不会改变这一点,但在下一个配方中,当使用蜡烛图表时,我们会重新审视这个想法。如果你勇敢的话,试着找出一个解决方案。
构建蜡烛图表(股票图表)
我们正要迈出一大步。到目前为止,我们使用的图表只有一个数据点,两个数据点,以及一些变化,现在我们要进入一个新世界,每个柱状图有四个数据点。股票图表是展示市场在特定时间范围内(在我们的例子中是一天)变化的一种方式。每天股票价格会多次变动,但最重要的因素是当天的最低价、最高价以及开盘价和收盘价。股票分析师需要能够快速看到信息并理解总体趋势。我们跳过了三个数据点元素,但我们将在第四章“让曲线变得更加曲线”中的“构建气泡图表”中回到它们。
你能做的最糟糕的事情就是假设四个维度数据的唯一用途是在股票市场上。这就是你可以想出下一个大事件的地方。以清晰快速的方式可视化数据,并将数据转化为逻辑是关于图表最有趣的事情之一。说了这么多,让我们开始创建我们的股票图表。
准备工作
我们在这个配方中的第一步会有点不同。我创建了一个名为DJI.txt
的示例 CSV 文件;你可以在我们的源文件中找到它。格式是标准的 CSV 格式,第一行命名了所有数据列。
DATE,CLOSE,HIGH,LOW,OPEN,VOLUME
未来的所有行都包含数据(在我们的例子中是每日数据):
1309752000000,12479.88,12506.22,12446.05,12505.99,128662688
所以我们需要经历的步骤是加载文件,将数据转换为适合我们标准数据集的格式,然后构建新的图表类型(然后在发现问题时进行修复;敏捷开发)。
如何做...
我们将从上一个步骤离开的地方开始工作。我们将直接在 JavaScript 文件中开始修改:
- 让我们更新全局变量:
var chartInfo= { y:{min:11500, max:12900,steps:5,label:"close"},
x:{min:1, max:12, steps:11,label:"date"}
};
var stockData;
var CHART_PADDING = 20;
var wid;
var hei
- 在开始内部逻辑之前,我们需要加载新的外部 CSV 文件。我们将重命名
init
函数并将其称为startUp
,然后创建一个新的init
函数:
function init(){
var client = new XMLHttpRequest();
client.open('GET', 'data/DJI.txt');
client.onreadystatechange = function(e) {
if(e.target.readyState==4){
var aStockInfo = e.target.responseText.split("\n");
stockData = translateCSV(aStockInfo,7);
startUp()
}
}
client.send();
}
function startUp(){
//old init function
}
- 我们从 CSV 文件中获取的数据需要格式化为我们可以使用的结构。为此,我们创建了
translateCSV
函数,该函数接受原始 CSV 数据并将其转换为与我们架构需求匹配的对象:
function translateCSV(data,startIndex){
startIndex|=1; //if nothing set set to 1
var newData = [];
var aCurrent;
var dataDate;
for(var i=startIndex; i<data.length;i++){
aCurrent = data[i].split(",");
dataDate = aCurrent[0].charAt(0)=="a"?parseInt(aCurrent[0].slice(1)):parseInt(aCurrent[0]);
newData.push({ date:dataDate,
close:parseFloat(aCurrent[1]),
high:parseFloat(aCurrent[2]),
low:parseFloat(aCurrent[3]),
open:parseFloat(aCurrent[4]),
volume:parseFloat(aCurrent[5])
});
}
return newData;
}
- 我们的
startUp
函数,以前称为init
,除了将createWaterfall
方法更改为调用addStock
之外,将保持不变:
function startUp(){
...
addStock(context,stockData);
}
- 是时候创建
addStock
函数了:
function addStock(context,data){
fillChart(context,chartInfo);
var elementWidth =(wid-CHART_PADDING*2)/ data.length;
var startY = CHART_PADDING;
var endY = hei-CHART_PADDING;
var chartHeight = endY-startY;
var stepSize = chartHeight/(chartInfo.y.max-chartInfo.y.min);
var openY;
var closeYOffset;
var highY;
var lowY;
var currentX;
context.strokeStyle = "#000000";
for(i=0; i<data.length; i++){
openY = (data[i].open-chartInfo.y.min)*stepSize;
closeYOffset = (data[i].open-data[i].close)*stepSize;
highY = (data[i].high-chartInfo.y.min)*stepSize;
lowY =(data[i].low-chartInfo.y.min)*stepSize;
context.beginPath();
currentX = CHART_PADDING +elementWidth*(i+.5);
context.moveTo(currentX,endY-highY);
context.lineTo(currentX,endY-lowY);
context.rect(CHART_PADDING +elementWidth*i ,endY-openY,elementWidth,closeYOffset);
context.stroke();
context.fillStyle = closeYOffset<0? "#C2D985" :"#E3675C" ;
context.fillRect(CHART_PADDING +elementWidth*i ,endY-openY,elementWidth,closeYOffset);
}
}
创建新的蜡烛图表需要执行所有这些步骤。
工作原理...
让我们回顾一下加载外部文件的步骤。如果您使用 jQuery 等开源工具,最好使用它们来加载外部文件,但是为了避免使用其他库,我们将使用XMLHttpRequest
对象,因为它在支持 HTML5 的所有现代浏览器中都受支持。
我们首先创建一个新的XMLHttpRequest
对象:
var client = new XMLHttpRequest();
client.open('GET', 'data/DJI.txt');
下一步是设置我们想要执行的操作(GET/POST)和文件名,然后创建onreadystatechange
回调的处理程序函数并发送我们的请求。
client.onreadystatechange = function(e) {
if(e.target.readyState==4){
var aStockInfo = e.target.responseText.split("\n");
stockData = translateCSV(aStockInfo,1);
startUp()
}
}
client.send();
事件处理程序onreadystatechange
在文件加载过程中会被调用几次。我们只想在文件加载完成并准备好处理时监听并执行操作;为此,我们将检查readyState
变量是否等于四(准备就绪和已加载)。文件加载完成后,我们希望根据换行符将文件拆分为数组。
注意
请注意,该文件是在 Mac 上创建的。\n
起到了作用,但是当您创建自己的文件或下载文件时,您可能需要使用\r
或组合\n\r
或\n\r
。始终通过输出数组的长度并验证其正确大小(然后测试其内容是否符合您的预期)来确认您是否做出了正确的选择。
在我们的数组准备就绪后,我们希望将其格式化为用户友好的格式,然后启动旧的init
函数,现在称为startUp
。
让我们快速回顾一下translateCSV
格式化函数。我们实际上是在循环遍历之前创建的数据数组,并用格式化的对象替换每一行,这将适用于我们的需求。请注意,我们有一个可选参数startIndex
。如果未设置任何值或设置为零,则在第一行上我们将其赋值为1
:
startIndex||=1; //if nothing set set to 1
前者是一种简写的写法:
startIndex = startIndex || 1;
如果startIndex
参数的值等于 true,则保持不变;否则,将其转换为1
。
顺便说一句,如果您不知道如何使用这些快捷方式,我真的建议您熟悉它们;它们非常有趣,可以节省时间和输入。如果您想了解更多,请查看以下链接:
太好了!现在我们有了一个数据源,其格式与我们迄今为止使用的样式相同。
我们将硬编码我们的chartInfo
对象。它对我们的y
值效果很好,但对我们的日期要求(在x
值中)效果不是很好。在我们的图表运行后,我们将稍后重新讨论这个问题。在早期的练习中,我们创建了一个动态范围生成器,所以如果您想跟上这个,那么请回顾一下,并将这种类型的逻辑也添加到这个图表中,但对于我们的需求,我们现在将它硬编码。
好的,让我们深入了解addStock
函数。顺便说一下,注意到我们正在使用相同的格式和整体工具,我们可以轻松地混合图表。但在这之前,让我们了解一下addStock
函数实际上是做什么的。让我们从我们的基本变量开始:
fillChart(context,chartInfo);
var elementWidth =(wid-CHART_PADDING*2)/ data.length;
var startY = CHART_PADDING;
var endY = hei-CHART_PADDING;
var chartHeight = endY-startY;
var stepSize = chartHeight/(chartInfo.y.max-chartInfo.y.min);
我们正在收集信息,这将使我们在创建柱状图时更容易工作(从元素的宽度elementWidth
到我们的值和图表高度之间的比率)。所有这些变量在本章的早期示例中都有涉及。
var openY;
var closeYOffset;
var highY;
var lowY;
var currentX;
context.strokeStyle = "#000000";
这些变量将成为我们的辅助变量(在每轮循环后更新),用于确定高、低、开盘和收盘偏移的位置(因为我们正在绘制一个矩形,它期望高度而不是第二个y
值)。
在我们的循环的每一轮中,我们首先找出这些变量的值:
for(i=0; i<data.length; i++){
openY = (data[i].open-chartInfo.y.min)*stepSize;
closeYOffset = (data[i].open-data[i].close)*stepSize;
highY = (data[i].high-chartInfo.y.min)*stepSize;
lowY =(data[i].low-chartInfo.y.min)*stepSize;
您会注意到,所有变量的逻辑几乎都是相同的。我们只是从值中减去最小值(因为我们的图表不包括最小值以下的值),然后将其乘以我们的stepSize
比率,使值适应我们的图表尺寸(这样即使我们改变图表尺寸,一切都应该继续工作)。请注意,只有closeYOffset
变量不是减去min
属性,而是减去close
属性。
下一步是绘制我们的蜡烛图表,从当天的最低点到最高点开始画一条线:
context.beginPath();
currentX = CHART_PADDING +elementWidth*(i+.5);
context.moveTo(currentX,endY-highY);
context.lineTo(currentX,endY-lowY);
接下来是代表完整开盘和收盘值的矩形:
context.rect(CHART_PADDING +elementWidth*i ,endY-openY,elementWidth,closeYOffset);
context.stroke();
context.fillStyle = closeYOffset<0? "#C2D985" :"#E3675C" ;
context.fillRect(CHART_PADDING +elementWidth*i ,endY-openY,elementWidth,closeYOffset);
}
之后,我们将为这个矩形创建一个填充,并根据closeYOffset
变量的值设置样式颜色。在这个阶段,我们有一个运行中的应用程序,尽管它可能需要一些调整来使其运行得更好。
还有更多...
是时候修复我们的x
坐标值了:
var chartInfo= { y:{min:11500, max:12900,steps:5,label:"close"},
x:{min:1, max:12, steps:11,label:"date"}
};
在此之前,我们没有改变这个变量,因为到目前为止,轮廓和我们的内容(图表本身)之间有明显的分离;但在这个阶段,由于我们的x
轮廓内容不再是一个线性数字,而是一个日期;我们需要以某种方式将与图表内容相关的外部数据引入fillChart
方法。这里最大的挑战是,我们不想在这个方法中引入仅与我们的图表相关的东西,因为这是一个全局使用的函数。相反,我们希望将我们的唯一数据放在一个外部函数中,并将该函数作为格式化程序发送进去。所以让我们开始吧:
var chartInfo= { y:{min:11500, max:12900,steps:5,label:"close"},
x:{label:"date",formatter:weeklyCapture}
};
我们股票图表中的x
空间代表时间,因此我们以前基于线性数据的使用不适用(例如min
、max
和steps
等属性在这种情况下没有意义)。我们将删除它们,而是使用一个新属性formatter
,它将以函数作为其值。我们将使用这个formatter
函数而不是默认函数。如果设置了这个函数,我们将让一个外部函数定义规则。当我们描述weeklyCapture
函数时,我们将会更多地了解这一点。这种编码方法称为插件编码。它的名称源自这样一个想法,即使我们在未来重新设计核心逻辑,也能够创建可替换的函数。在创建weeklyCapture
函数之前,让我们调整chartInfo
对象,以便获得正确的范围和步数:
function addStock(context,data){
if(!chartInfo.x.max){
chartInfo.x.min = 0;
chartInfo.x.max = data.length;
chartInfo.x.steps = data.length;
}
fillChart(context,chartInfo);
...
我们在这里要做的是,在addStock
函数中调用fillChart
函数之前,我们要检查max
值是否已设置;如果没有设置,我们将重置所有值,将min
设置为0
,将max
和steps
设置为数据数组的长度。我们这样做是因为我们想要遍历所有的数据元素,以测试并查看是否有新的工作日。
现在我们将weeklyCapture
函数整合到fillChart
函数中。
function fillChart(context, chartInfo){
// ....
var output;
for(var i=0; i<steps; i++){
output = chartInfo.x.formatter && chartInfo.x.formatter(i);
if(output || !chartInfo.x.formatter){
currentX = startX + (i/steps) * chartWidth;
context.moveTo(currentX, startY );
context.lineTo(currentX,endY);
context.fillText(output?output:xData.min+stepSize*(i), currentX-6, endY+CHART_PADDING/2);
}
}
if(!chartInfo.x.formatter){
currentX = startX + chartWidth;
context.moveTo(currentX, startY );
context.lineTo(currentX,endY);
context.fillText(xData.max, currentX-3, endY+CHART_PADDING/2);
}
context.stroke();
}
在我们的第一步中,我们将获取从我们的formatter
函数返回的值。
output = chartInfo.x.formatter && chartInfo.x.formatter(i);
逻辑很简单,我们正在检查formatter
函数是否存在,如果存在,我们就调用它并发送当前值i
(因为我们在循环中)。
接下来的步骤是,如果我们的输出不为空(负数或等于 false 的值),或者如果我们的输出为空但我们的格式化程序没有激活,那么渲染数据:
if(output || !chartInfo.x.formatter){
currentX = startX + (i/steps) * chartWidth;
context.moveTo(currentX, startY );
context.lineTo(currentX,endY);
context.fillText(output?output:xData.min+stepSize*(i), currentX-6, endY+CHART_PADDING/2);
}
只有当我们从formatter
函数获得输出和/或formatter
函数不存在时,我们才不需要做任何事情。因此,我们需要if
语句来捕获两种情况,如果我们没有if
语句,那么我们的输出将不符合我们之前的规则。我们在这段代码块中更改的唯一内容是fillText
方法。如果我们正在处理输出,我们希望将其用于文本。如果没有,我们希望保持之前的逻辑不变:
if(output || !chartInfo.x.formatter){
currentX = startX + (i/steps) * chartWidth;
context.moveTo(currentX, startY );
context.lineTo(currentX,endY);
context.fillText(output?output:xData.min+stepSize*(i), currentX-6, endY+CHART_PADDING/2);
}
在我们运行应用程序并查看它运行的最后一件事是创建我们的weeklyCapture
函数。所以现在让我们创建它:
var DAY = 1000*60*60*24;
function weeklyCapture(i){
var d;
if(i==0){
d = new Date(stockData[i].date);
}else if ( i>1 && stockData[i].date != stockData[i-1].date+1 ){
d = new Date(stockData[i].date + DAY*stockData[i].date );
}
return d? d.getMonth()+1+"/"+d.getDate():false;
}
我们首先创建一个名为DAY
的辅助变量,它将存储一天有多少毫秒:
var DAY = 1000*60*60*24;
如果您查看我们的外部数据,您会发现只有在第0
天我们有一个实际日期(以自 1970 年以来的毫秒格式化)。我们只需要将其发送到date
对象以创建一个日期:
var d;
if(i==0){
d = new Date(stockData[i].date);
}
虽然所有其他数据行只包含一个数字,表示自原始日期以来经过了多少天,但我们想要测试并查看当前日期是否仅比上一个日期晚一天。只有当日期变化大于一天时,我们才会为其创建新的日期格式:
}else if ( i>1 && stockData[i].date != stockData[i-1].date+1 ){
d = new Date(stockData[0].date + DAY*stockData[i].date );
}
请注意,要创建日期对象,我们需要获取第0
行的当前原始日期,然后将其与毫秒中的总天数相加(将我们的DAY
变量乘以当前天数值)。
通过这种方法,我们只需要检查是否有有效的日期。让我们格式化它并发送回去,如果没有,我们将发送回false
:
return d? d.getMonth()+1+"/"+d.getDate():false;
恭喜!现在我们的示例是一个完全集成的蜡烛图表,具有动态日期。
向我们的股票图表添加其他渲染选项
尽管蜡烛图表是一个非常受欢迎的选项,但还有另一种受欢迎的技术图表视图。当没有颜色可用时使用的一种视图。在左侧绘制一条线来定义开盘价,在右侧捕获收盘价。让我们将该逻辑集成到我们的图表中作为可选的渲染模式。我们将向addStock
函数添加一个新参数:
function addStock(context,data,isCandle){
现在,我们将调整我们的内部for
循环,以根据这个变量的值改变渲染:
for(i=0; i<data.length; i++){
openY = (data[i].open-chartInfo.y.min)*stepSize;
closeYOffset = (data[i].open-data[i].close)*stepSize;
highY = (data[i].high-chartInfo.y.min)*stepSize;
lowY =(data[i].low-chartInfo.y.min)*stepSize;
context.beginPath();
currentX = CHART_PADDING +elementWidth*(i+.5);
context.moveTo(currentX,endY-highY);
context.lineTo(currentX,endY-lowY);
if(!isCandle){
context.moveTo(currentX,endY-openY);
context.lineTo(CHART_PADDING +elementWidth*(i+.25),endY-openY);
context.moveTo(currentX,endY-openY+closeYOffset);
context.lineTo(CHART_PADDING +elementWidth*(i+.75),endY-openY+closeYOffset);
context.stroke();
}else{
context.rect(CHART_PADDING +elementWidth*i ,endY-openY,elementWidth,closeYOffset);
context.stroke();
context.fillStyle = closeYOffset<0? "#C2D985" :"#E3675C" ;
context.fillRect(CHART_PADDING +elementWidth*i ,endY-openY,elementWidth,closeYOffset);
}
}
这样就可以了。我们将isCandle
布尔变量的默认值设置为false
。如果我们再次运行我们的应用程序,我们会发现它以新格式呈现。要更改这一点,我们只需要在调用addStock
函数时提供第三个参数为true
:
本章是自包含的,真正是所有图表的中心,如果您需要加强图表构建技能。我建议您重新查看本章中的一些早期示例。
第四章:让事情变得曲线
在本章中,我们将涵盖:
-
构建气泡图
-
创建饼图
-
使用甜甜圈图表显示关系
-
利用雷达
-
构建树状图
介绍
在上一章中,我们构建了一个用于线性图的组件,通过点、线和条形图进行范围。我们处理的大部分数据是二维的,而我们在四维图表结束了我们的课程。它仍然使用线性艺术来表示。在本章中,我们将利用创建非线性数据来表示数据的能力。
构建气泡图
尽管我们图表中的许多项目与我们在第三章中创建的图表有关,但我们将从头开始。我们的目标是创建一个具有气泡的图表——气泡使我们能够展示具有三个数据点(x、y 和气泡大小)的数据。这种类型的图表在动画时非常理想,因为它可以展示随时间的变化(它可以在几秒钟内展示多年的变化)。
气泡图的强大功能在 Hans Rosling 的 TED 演示中可以得到很好的展示(blog.everythingfla.com/2012/05/hans-rosling-data-vis.html
)。
准备就绪
我们将从画布设置开始启动我们的项目,并跳过 HTML 结尾。如果您忘记了如何创建,请参考第一章中的使用 2D 画布绘制图形。
有三个主要步骤:
-
创建数据源
-
创建背景
-
将图表数据信息添加到图表中
如何做...
让我们列出创建气泡图所需的步骤:
- 下一个数据对象应该看起来很熟悉,它是一个数组,其中包含有关学生在英语、数学和编程方面的成绩的对象。构建数据对象:
var students2001 = [{name:"Ben",
math:30,
english:60,
programing:30},
{name:"Joe",
math:40,
english:60,
programing:40},
{name:"Danny",
math:50,
english:90,
programing:50},
{name:"Mary",
math:60,
english:60,
programing:60},
{name:"Jim",
math:80,
english:20,
programing:80}];
- 创建我们的图表信息;与以前的图表相反,这个图表有一个用于气泡信息的第三个参数。定义我们的图表规则:
var chartInfo= { y:{min:0, max:100,steps:5,label:"math"},
x:{min:0, max:100,steps:5,label:"programing"},
bubble:{min:0, max:100, minRaduis:3, maxRaduis:20,label:"english"}
};
- 最后的数据对象将包含我们将来可能想要更改的所有样式信息。添加一个样式对象:
var styling = { outlinePadding:4,
barSize:16,
font:"12pt Verdana, sans-serif",
background:"eeeeee",
bar:"cccccc",
text:"605050"
};
- 当文档准备就绪时,我们创建一个事件回调来触发
init
,因此让我们创建init
函数:
var wid;
var hei;
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
var context = can.getContext("2d");
createOutline(context,chartInfo);
addDots(context,chartInfo,students2001,["math","programing","english"],"name");
}
- 当我们创建样式对象时,我们开始创建我们的轮廓。现在是时候将一切绘制到我们的画布中了。因此,我们首先设置我们的基本画布样式:
function createOutline(context,chartInfo){
var s = styling;
var pad = s.outlinePadding;
var barSize = s.barSize;
context.fillStyle = s.background;
context.fillRect(0,0,wid,hei);
context.fillStyle = s.bar;
context.fillRect(pad,pad,barSize,hei-pad*2);
context.font = s.font;
context.fillStyle = s.text;
- 我们需要保存我们当前的基于画布的图形布局信息,对其进行更改以使其更容易定位元素,然后将其恢复到原始状态:
context.save();
context.translate(17, hei/2 );
context.rotate(-Math.PI/2);
context.textAlign = "center";
context.fillText(chartInfo.y.label, 0, 0);
context.restore();
context.fillStyle = s.bar;
context.fillRect(pad+barSize,hei-pad-barSize,wid-pad*2-barSize,barSize);
context.font = s.font;
context.fillStyle = s.text;
context.fillText(chartInfo.x.label,( wid-pad*2-barSize)/2, hei-pad*2);
context.translate(pad+barSize,hei-pad-barSize);
context.scale(1, -1);
//SET UP CONSTANTS - NEVER CHANGE AFTER CREATED
styling.CHART_HEIGHT = hei-pad*2-barSize;
styling.CHART_WIDTH = wid-pad*2-barSize;
- 现在是时候借助我们的
chartInfo
对象绘制轮廓了:
var steps = chartInfo.y.steps;
var ratio;
chartInfo.y.range = chartInfo.y.max-chartInfo.y.min;
var scope = chartInfo.y.range;
context.strokeStyle = s.text;
var fontStyle = s.font.split("pt");
var pointSize = fontStyle[0]/2;
fontStyle[0]=pointSize;
fontStyle = fontStyle.join("pt");
context.font = fontStyle; // making 1/2 original size of bars
for(var i=1; i<=steps; i++){
ratio = i/steps;
context.moveTo(0,ratio*styling.CHART_HEIGHT-1);
context.lineTo(pad*2,ratio*styling.CHART_HEIGHT-1);
context.scale(1,-1);
context.fillText(chartInfo.y.min + (scope/steps)*i,0,(ratio*styling.CHART_HEIGHT-3 -pointSize)*-1);
context.scale(1,-1);
}
steps = chartInfo.x.steps;
chartInfo.x.range = chartInfo.x.max-chartInfo.x.min;
scope = chartInfo.x.max-chartInfo.x.min;
context.textAlign = "right";
for(var i=1; i<=steps; i++){
ratio = i/steps;
context.moveTo(ratio*styling.CHART_WIDTH-1,0);
context.lineTo(ratio*styling.CHART_WIDTH-1,pad*2);
context.scale(1,-1);
context.fillText(chartInfo.x.min + (scope/steps)*i,ratio*styling.CHART_WIDTH-pad,-pad/2);
context.scale(1,-1);
}
context.stroke();
}
- 现在是时候通过创建
addDots
方法将数据添加到我们的图表中了。addDots
函数将使用规则(键)来接收数据的定义,与我们在之前的食谱中所做的相反。
function addDots(context,chartInfo,data,keys,label){
var rangeX = chartInfo.y.range;
var _y;
var _x;
var _xoffset=0;
var _yoffset=0;
if(chartInfo.bubble){
var range = chartInfo.bubble.max-chartInfo.bubble.min;
var radRange = chartInfo.bubble.maxRadius-chartInfo.bubble.minRadius;
context.textAlign = "left";
}
for(var i=0; i<data.length; i++){
_x = ((data[i][keys[0]] - chartInfo.x.min )/ chartInfo.x.range) * styling.CHART_WIDTH;
_y = ((data[i][keys[1]] - chartInfo.y.min )/ chartInfo.y.range) * styling.CHART_HEIGHT;
context.fillStyle = "#44ff44";
if(data[i][keys[2]]){
_xoffset = chartInfo.bubble.minRadius + (data[i][keys[2]]-chartInfo.bubble.min)/range *radRange;
_yoffset = -3;
context.beginPath();
context.arc(_x,_y, _xoffset , 0, Math.PI*2, true);
context.closePath();
context.fill();
_xoffset+=styling.outlinePadding;
}else{
context.fillRect(_x,_y,10,10);
}
if(label){
_x+=_xoffset;
_y+=_yoffset;
context.fillStyle = styling.text;
context.save();
context.translate(_x,_y );
context.scale(1,-1);
context.fillText("Bluping",0,0);
context.restore();
}
}
}
这一段代码虽然是从头开始重新编写的,但与第三章中的在散点图中传播数据食谱有很多相似之处,通过修改使其能够处理第三级数据和新的图表格式。
就是这样。您应该有一个运行中的气泡图。现在当您运行应用程序时,您将看到x
参数展示了数学成绩,y
参数展示了编程成绩,而我们气泡的大小展示了学生的英语成绩。
它是如何工作的...
让我们从createOutline
函数开始。在这个方法中,除了我们喜欢的常规画布绘制方法之外,我们还引入了一种新的编码风格,在这种风格中,我们操纵实际的画布来帮助我们以更简单的方式定义我们的代码。这里的两个重要关键方法如下:
context.save();
context.restore();
我们将多次利用save
方法。该方法保存画布的当前视图,而restore
方法将用户返回到上次保存的画布:
context.save();
context.translate(17, hei/2 );
context.rotate(-Math.PI/2);
context.textAlign = "center";
context.fillText(chartInfo.y.label, 0, 0);
context.restore();
在第一次使用这种样式时,我们将其用于通过将其旋转到右侧来绘制我们的文本。translate
方法移动画布的0,0
坐标,而rotate
方法使用弧度旋转文本。
在绘制外部条之后,是时候利用这种新能力了。大多数图表依赖于 y 坐标向上增长,但是这个画布的 y 值是从画布区域的顶部向底部增长的。我们可以通过在循环之前添加一些代码来翻转这种关系。
context.translate(pad+barSize,hei-pad-barSize);
context.scale(1, -1);
在前面的行中,我们首先将画布的0,0
坐标移动到图表的右下范围,然后通过切换比例值翻转画布。请注意,从现在开始,如果我们尝试向画布添加文本,它将是颠倒的。请记住,因为我们现在正在绘制一个颠倒的画布。
在我们的第一个循环中,当我们尝试输入新文本时,需要注意的一点是,当我们想要添加文本时,我们首先撤消我们的比例,然后将画布返回以进行翻转:
context.scale(1,-1);
context.fillText(chartInfo.y.min + (scope/steps)*i,0,(ratio*styling.CHART_HEIGHT-3 -pointSize)*-1);
context.scale(1,-1);
请注意,我们将 y 坐标乘以*-1
。我们这样做是因为我们实际上希望 y 坐标的值为负数,因为我们刚刚翻转了屏幕。
关于 x 条文本的工作方式非常相似;请注意与查找 x 和 y 值计算相关的主要区别。
现在是深入了解addDots
函数的时候了。如果您一直在关注第三章,创建基于笛卡尔的图表,那么这个函数会再次让您感到熟悉,但这次我们使用的是修改后的画布。
我们首先使用一些辅助变量:
var rangeX = chartInfo.y.range;
var _y;
var _x;
var _xoffset=0;
var _yoffset=0;
我们动态添加气泡效果,这意味着即使只有两个信息点而不是三个,该方法也可以工作。我们继续测试我们的数据对象是否包含气泡信息:
if(chartInfo.bubble){
var range = chartInfo.bubble.max-chartInfo.bubble.min;
var radRange = chartInfo.bubble.maxRaduis-chartInfo.bubble.minRaduis;
context.textAlign = "left";
}
如果是这样,我们将添加一些变量并将我们的文本对齐到左侧,因为我们将在这个示例中使用它。
是时候浏览我们的数据对象并在图表上传播数据了。
for(var i=0; i<data.length; i++){
_x = ((data[i][keys[0]] - chartInfo.x.min )/ chartInfo.x.range) * styling.CHART_WIDTH;
_y = ((data[i][keys[1]] - chartInfo.y.min )/ chartInfo.y.range) * styling.CHART_HEIGHT;
context.fillStyle = "#44ff44";
对于每个循环,我们根据当前值重新计算_x
和_y
坐标。
如果我们有第三个元素,我们就准备开发一个气泡。如果没有,我们需要创建一个简单的点。
if(data[i][keys[2]]){
_xoffset = chartInfo.bubble.minRaduis + (data[i][keys[2]]-chartInfo.bubble.min)/range *radRange;
_yoffset = -3;
context.beginPath();
context.arc(_x,_y, _xoffset , 0, Math.PI*2, true);
context.closePath();
context.fill();
_xoffset+=styling.outlinePadding;
}else{
context.fillRect(_x,_y,10,10);
}
在这个阶段,我们应该有一个活跃的气泡/点方法。我们所要做的就是集成我们的覆盖副本。
在添加标签之前,让我们看一下函数签名:
function addDots(context,chartInfo,data,keys,label){}
context
和chartInfo
参数在我们的示例中已经是标准的。键的想法是使我们能够动态切换要测试的数据。键的值是与 x 和 y 坐标相关的数组位置0
和1
,位置2
用于气泡,正如我们之前所见。label
参数使我们能够发送标签的键值。通过这种方式,如果标签存在,我们将添加一个标签,如果不存在,我们将不添加。
if(label){
_x+=_xoffset;
_y+=_yoffset;
context.fillStyle = styling.text;
context.save();
context.translate(_x,_y );
context.scale(1,-1);
context.fillText(data[i][label],0,0);
context.restore();
}
然后我们添加前面的if
语句。如果我们设置了标签,我们就定位样式并创建标签的文本。
创建饼图
创建饼图的步骤相对简单而短。饼图非常适合展示我们想要在数据字段之间轻松比较的封闭数据量,例如,在我们的示例中,根据其地区将世界上的人数分组:
准备工作
第一步将是在 HTML 区域更新我们的画布大小为矩形区域。在我们的示例中,我们将更新值为 400 x 400。就是这样;让我们开始建立它。
如何做...
在接下来的步骤中,我们将创建我们的第一个饼图。让我们开始吧:
- 设置我们的数据源和全局变量:
var data= [ {label:"Asia", value:3518000000,style:"#B1DDF3"},
{label:"Africa", value:839000000,style:"#FFDE89"},
{label:"Europe", value:803000000,style:"#E3675C"},
{label:"Latin America and Caribbean", value: 539000000,style:"#C2D985"},
{label:"North America", value:320000000,style:"#eeeeee"},
{label:"Near East", value:179000000,style:"#aaaaaa"},
{label:"Oceania", value:32000000,style:"#444444"}
];
var wid;
var hei;
var radius = 100;
- 准备我们的画布(从这里开始我们将深入
init
函数):
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
var context = can.getContext("2d");
...
- 计算总数据(世界人口):
var total=0;
for(var i=0; i<data.length; i++) total+=data[i].value;
- 设置 360 度的弧度并将我们的旋转点移动到
0,0
:
var rad360 = Math.PI*2;
context.translate(wid/2,hei/2);
- 使用以下代码片段绘制饼图:
var currentTotal=0;
for(i=0; i<data.length; i++){
context.beginPath();
context.moveTo(0,0);
context.fillStyle = data[i].style;
context.arc( 0,0,radius,currentTotal/total*rad360,(currentTotal+data[i].value)/total*rad360,false);
context.lineTo(0,0);
context.closePath();
context.fill();
currentTotal+=data[i].value;
}
}
就是这样;我们刚刚创建了一个基本的饼图——我告诉过你这很容易!
它是如何工作的……
正如其名称所示,我们的饼图使用饼图,并始终展示 100%的数据。由于我们的弧线方法是基于弧度的,我们需要将这些数据点从百分比转换为弧度。
在弄清楚所有值的总和以及圆圈中的总弧度(2*PI
)之后,我们准备循环并绘制切片。
var currentTotal=0;
for(i=0; i<data.length; i++){
context.beginPath();
context.moveTo(0,0);
context.fillStyle = data[i].style;
逻辑相对简单;我们循环遍历所有数据元素,根据数据对象改变填充样式,并将指针移动到0,0
(将画布的中心点作为我们的旋转点)。
context.arc( 0,0,radius,currentTotal/total*rad360,(currentTotal+data[i].value)/total*rad360,false);
context.lineTo(0,0);
context.closePath();
context.fill();
currentTotal+=data[i].value;
现在我们来绘制弧线。注意高亮显示的文本;我们从当前总数结束的地方开始,并通过这个计算弧度角度:
currentTotal/total*rad360
我们可以将这个值转换为我们可以对圆圈的总弧度进行复制的百分比值。我们的第二个参数非常接近,所以我们只需将当前值添加到我们所在的当前区域的当前值中:
(currentTotal+data[i].value)/total*rad360
最后要注意的一点是,我们将弧线的最后一个参数设置为false
(逆时针),因为这对我们的计算效果最好。
最后但并非最不重要的是,我们将更新我们的currentTotal
值,以包括新添加的区域,因为这将是我们for
循环的下一轮中的起始点。
还有更多……
一个没有任何内容信息的饼图可能不会像有信息的图表那样有效,但我们可以找出位置……别担心;我们将重新审视我们的老朋友cos
和sin
来帮助我们定位圆圈上的点,以便我们能够在我们新创建的饼图上添加文本信息。
重新审视 Math.cos()和 Math.sin()
我们将首先添加一个新的全局变量来存储我们线条的颜色,然后我们将称之为copyStyle
:
var copyStyle = "#0000000000";
现在我们回到了init
函数,让我们在最后一行之前的for
循环中添加它:
currentTotal+=data[i].value;
正如预期的那样,我们将首先将我们的新copyStyle
变量设置为我们的填充和描边值:
context.strokeStype = context.fillStyle = copyStyle;
我们的下一步是确定我们想要在饼图中的哪个位置绘制一条线,以便我们可以添加文本:
midRadian = (currentTotal+data[i].value/2)/total*rad360;
为了实现这一点,我们将使用一个新变量来存储上一个总数和新值(新切片的中心)的中间值。到目前为止还不错。现在我们需要弄清楚如何获得该点的 x 和 y 位置。幸运的是,在圆圈中有一种非常简单的方法,就是使用Math.cos
(对于 x)和Math.sin
(对于 y)函数:
context.beginPath();
context.moveTo(Math.cos(midRadian)*radius,Math.sin(midRadian)*radius);
context.lineTo(Math.cos(midRadian)*(radius+20),Math.sin(midRadian)*(radius+20));
context.stroke();
拥有我们的midRadian
变量,我们将得到一个半径为1
的圆的值,所以我们要做的就是将这个值乘以我们真正的半径来找到我们的起始点。由于我们想要在同一方向上绘制一条线到外部的弧线,我们将找到一个更大的虚拟圆的点;所以我们将使用相同的公式,但是将我们的半径值升级 20,创建一个与弧线相关的对角线。
我们要做的就是弄清楚我们想要在图表中放置什么文本,使用相同的弧线点和更大的圆圈尺寸:
context.fillText(data[i].label,Math.cos(midRadian)*(radius+40),Math.sin(midRadian)*(radius+40));
看起来不错……唯一的问题是我们没有我们的值;让我们添加它们并弄清楚涉及其中的挑战。
改进我们气泡的文本格式
在实际应用中,如果这是一个实时应用程序,我们可能希望使用悬停效果(我们将在后面的章节中讨论这个想法),但让我们尝试找出一种创建包含所有信息的图表的方法。我们在前一行代码中停下来,有一个非常大的外圆(radius+40
)。这是因为我们想在下面插入一行新的文本,所以让我们来做吧:
context.fillText(formatToMillions(data[i].value) + "(" +formatToPercent(data[i].value/total) + ")" ,Math.cos(midRadian)*(radius+40),Math.sin(midRadian)*(radius+40) + 12);
这有点啰嗦,但基本上与前一行相同,只是多了一行文本和一个额外的更改,因为我们将 y 值向上移动 12 像素以适应同一区域上的第一行文本。为了使其工作,我们使用了两个帮助函数来格式化我们的文本:
function formatToPercent(val){
val*=10000;
val = parseInt(val);
val/=100;
return val + "%"
}
function formatToMillions(val){
val/=1000000;
return val + "Million";
}
如果以当前格式运行应用程序,您会发现文本在页面上看起来不好,这就是您内心的艺术家需要解决的问题。我一直在我们的源文件中继续示例,直到感觉合适,所以请查看它或从这里开始创建您自己的变体。
使用甜甜圈图表显示关系
甜甜圈图表是一种花哨的饼图。因此,如果您还没有创建过饼图,我强烈建议您重新查看上一个示例,创建饼图。甜甜圈图表是一种分层饼图。这种图表非常适合压缩适合饼图的数据类型之间的可比数据:
准备工作
我们将从上一个示例中获取我们的代码,并调整它以满足我们的需求。因此,我们将从相同的 HTML 文件和上一个示例中的相同代码开始。
如何做...
执行以下步骤:
- 让我们使用一些虚拟数据更新我们的数据(我们将创建两个数据对象):
var data1= [ {label:"Asia", value:3518000000,style:"#B1DDF3"},
{label:"Africa", value:839000000,style:"#FFDE89"},
{label:"Europe", value:803000000,style:"#E3675C"},
{label:"Latin America and Caribbean", value: 539000000,style:"#C2D985"},
{label:"North America", value:320000000,style:"#999999"},
{label:"Near East", value:179000000,style:"#666666"}
];
var data2= [ {label:"Asia", value:151000,style:"#B1DDF3"},
{label:"Africa", value:232000,style:"#FFDE89"},
{label:"Europe", value:842000,style:"#E3675C"},
{label:"Latin America and Caribbean", value: 538100,style:"#C2D985"},
{label:"North America", value:3200,style:"#999999"},
{label:"Near East", value:17900,style:"#666666"}
];
- 通过提取所有创建饼图的行到一个单独的函数并添加一个新函数
createHole
(用于我们的甜甜圈)来修改init
函数:
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
var context = can.getContext("2d");
context.translate(wid/2,hei/2);
createPie(context,data1,190);
createPie(context,data2,150);
createHole(context,100);
}
- 修改饼图创建以改变文本布局以适应饼图:
function createPie(context,data,radius){
var total=0;
for(var i=0; i<data.length;i++) total+=data[i].value;
var rad360 = Math.PI*2;
var currentTotal=0;
var midRadian;
var offset=0;
for(i=0; i<data.length; i++){
context.beginPath();
context.moveTo(0,0);
context.fillStyle = data[i].style;
context.arc( 0,0,radius,currentTotal/total*rad360,(currentTotal+data[i].value)/total*rad360,false);
context.lineTo(0,0);
context.closePath();
context.fill();
context.strokeStype = context.fillStyle = copyStyle;
midRadian = (currentTotal+data[i].value/2)/total*rad360;
context.textAlign = "center";
context.fillText(formatToPercent(data[i].value/total),Math.cos(midRadian)*(radius-20),Math.sin(midRadian)*(radius-20) );
currentTotal+=data[i].value;
}
}
- 我们需要创建方法
createHole
(实际上是一个简单的圆):
function createHole(context,radius){
context.beginPath();
context.moveTo(0,0);
context.fillStyle = "#ffffff";
context.arc( 0,0,radius,0,Math.PI*2,false);
context.closePath();
context.fill();
}
就是这样!我们现在可以通过更改半径来创建一个无尽的甜甜圈,每次添加新层时使其变小。
它是如何工作的...
甜甜圈图表的核心逻辑与饼图相同。我们的主要重点实际上是重新格式化和重连内容以在视觉层面进行轮廓。因此,我们的工作的一部分是删除不相关的内容并进行所需的更新:
context.fillText(formatToPercent(data[i].value/total),Math.cos(midRadian)*(radius-20),Math.sin(midRadian)*(radius-20) );
需要注意的主要事情是,我们正在硬编码一个比当前半径小 20 的值。如果我们希望我们的样本适用于每种可能的选项,我们需要找出一种更智能的方法来生成这些数据,因为理想情况下,我们希望文本位于甜甜圈区域之间并且旋转,但我们以前做过类似的事情,所以我会留给你去探索。
还有更多...
虽然我们的甜甜圈已经创建并准备好了,但如果我们添加一些更多的信息,比如轮廓和图例,会有所帮助,因为我们从上一个示例中提取了大部分文本。
添加轮廓
我们将使用阴影来在我们的形状周围创建发光效果。最简单和最快的方法是重新访问init
函数并添加阴影信息以创建这种效果:
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
var context = can.getContext("2d");
context.translate(wid/2,hei/2);
context.shadowOffsetX = 0;
context.shadowOffsetY = 0;
context.shadowBlur = 8;
context.shadowColor = 'rgba(0, 0, 0, 0.5)';
createPie(context,data1,190);
createPie(context,data2,150);
createHole(context,100);
}
关键在于我们将 x 和 y 值的偏移量都设置为0
,因此我们的阴影被用作发光。从现在开始绘制的每个元素都将有一个阴影,这对我们来说非常完美。
创建图例
嘿,既然我们的甜甜圈中有一个巨大的洞,我们怎么把我们的图例放在一切的中间呢?有时候中间并不是最好看的东西,最好手动找出创建图例后的完美位置。
context.shadowColor = 'rgba(0, 0, 0, 0)';
context.translate(-35,-55);
createLegend(context,data1);
我们首先通过将其 alpha 设置为0
并移动我们的枢轴点来移除我们的阴影。(在创建图例后,我调整了这些数字,直到我满意为止。)
好的,我们准备使用createLegend
函数创建我们的图例:
function createLegend(context,data){
context.textAlign="left";
for(var i=0;i<data.length;i++){
context.fillStyle=data[i].style;
context.fillRect(0,i*20,10,10);
context.fillText(data[i].label,20,i*20+8);
}
}
我们已经完成了一个带有图例的完整的圆环图。
另请参阅
- 创建饼图配方
利用雷达
雷达图是非常被误解的图表,但它们真的很棒。雷达使我们能够以非常紧凑的方式展示大量可比较的数据。雷达图也被称为蜘蛛图。
提示
警告
您真的需要熟悉Math.cos
和Math.sin
函数,因为我们将在这种图表类型中多次使用它们。也就是说,如果您还不熟悉它们,最好从本章的开头开始,刷新一下您的记忆。
准备工作
和往常一样,我们将从具有init
回调的基本 HTML 页面开始。
提示
注意
雷达图实际上是一个线图,包裹在一个圆形中,涉及了很多不同的数学;但它的想法是一样的——我们不是将我们的数据水平展开,而是将我们的数据围绕一个中心点展开。
如何做...
让我们看看创建雷达图涉及哪些步骤:
- 创建/组织图表数据和实际数据:
var data=[{label:"Ben", style:"#E3675C", math:90,english:45,spanish:25,programing:90,bible:20,art:90},
{label:"Sharon", style:"#C2D985", math:100,english:90,spanish:60,programing:27,bible:80,art:20}];
var chartInfo= {steps:10, max:100, types:["math","english","spanish","programing","bible","art"]};
- 添加一些辅助变量和一个
init
函数:
var wid;
var hei;
var copyStyle = "#0000000000";
var radius = 180;
var radianOffset = Math.PI/2
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
var context = can.getContext("2d");
createSpider(context,chartInfo,data);
}
- 现在是创建
createSpider
函数的时候了:
function createSpider(context,chartInfo,data){
drawWeb(context,chartInfo,radius);
drawDataWeb(context,chartInfo,data,radius);
}
- 我们将雷达网的创建分为两个阶段。第一个是从网的中心出来的线,另一个是围绕这个中心点循环的实际网。让我们从第一步开始,然后在第二个循环中继续下一部分:
function drawWeb(context,chartInfo,radius){
chartInfo.stepSize = chartInfo.max/chartInfo.steps;
var hSteps = chartInfo.types.length;
var hStepSize = (Math.PI*2)/hSteps;
context.translate(wid/2,hei/2);
context.strokeStyle = "#000000";
for(var i=0; i<hSteps; i++){
context.moveTo(0,0);
context.lineTo(Math.cos(hStepSize*i + radianOffset)*(radius+20),Math.sin(hStepSize*i + radianOffset)*(radius+20));
}
var stepSize = radius/chartInfo.steps;
var cRad;
for(var i=1; i<=chartInfo.steps; i++){
cRad = i*stepSize;
context.moveTo(Math.cos(radianOffset)*cRad,Math.sin(radianOffset)*cRad);
for(var j=0;j<hSteps; j++){
context.lineTo(Math.cos(hStepSize*j + radianOffset)*cRad,Math.sin(hStepSize*j + radianOffset)*cRad);
}
context.lineTo(Math.cos(radianOffset)*cRad,Math.sin(radianOffset)*cRad);
}
context.stroke();
}
- 现在是时候整合我们的数据了:
function drawDataWeb(context,chartInfo,data,radius){
var hSteps = chartInfo.types.length;
var hStepSize = (Math.PI*2)/hSteps;
for(i=0; i<data.length; i++){
context.beginPath();
context.strokeStyle = data[i].style;
context.lineWidth=3;
cRad = radius*(data[i][chartInfo.types[0]]/chartInfo.max);
context.moveTo(Math.cos(radianOffset)*cRad,Math.sin(radianOffset)*cRad);
for(var j=1;j<hSteps; j++){
cRad = radius*(data[i][chartInfo.types[j]]/chartInfo.max);
context.lineTo(Math.cos(hStepSize*j + radianOffset)*cRad,Math.sin(hStepSize*j + radianOffset)*cRad);
}
cRad = radius*(data[i][chartInfo.types[0]]/chartInfo.max);
context.lineTo(Math.cos(radianOffset)*cRad,Math.sin(radianOffset)*cRad);
context.stroke();
}
}
恭喜,您刚创造了一个雷达/蜘蛛图。
它是如何工作的...
雷达图是我们更复杂的图表类型之一。到目前为止,它使用了很多 cos/sin 函数,但逻辑非常一致,因此相对简单。
让我们更深入地了解drawWeb
方法:
chartInfo.stepSize = chartInfo.max/chartInfo.steps;
var hSteps = chartInfo.types.length;
var hStepSize = (Math.PI*2)/hSteps;
context.translate(wid/2,hei/2);
context.strokeStyle = "#000000";
我们首先创建一些辅助变量,并重新定位我们的枢轴点到屏幕中心,以帮助我们进行计算。
for(var i=0; i<hSteps; i++){
context.moveTo(0,0);
context.lineTo(Math.cos(hStepSize*i + radianOffset)*(radius+20),Math.sin(hStepSize*i + radianOffset)*(radius+20));
}
然后,我们根据课程数量创建我们的尖峰,因为每门课程都将用一个尖峰表示。
现在是时候创建我们的蜘蛛网的互联网了,现在我们有了我们的核心构建块(尖峰):
var stepSize = radius/chartInfo.steps;
var cRad;
for(var i=1; i<=chartInfo.steps; i++){
cRad = i*stepSize;
context.moveTo(Math.cos(radianOffset)*cRad,Math.sin(radianOffset)*cRad);
for(var j=0;j<hSteps; j++){
context.lineTo(Math.cos(hStepSize*j + radianOffset)*cRad,Math.sin(hStepSize*j + radianOffset)*cRad);
}
context.lineTo(Math.cos(radianOffset)*cRad,Math.sin(radianOffset)*cRad);
}
context.stroke();
在这个多维循环中,我们逐步从一个圆上的一个点画线到下一个点(从一个尖峰点到下一个点),每次完成创建一个完整的形状时,我们的半径都会增长。我们在这里创建的每个形状代表学生分数增加了 10 分,因为我们的学生只能在 0 到 100 分之间得分。在这个示例中,我们可以忽略极端情况。(如果您的数据范围不是从 0 开始,您可能需要调整此代码。)
虽然我们的drawDataWeb
方法会改变基于分数的半径,假设范围是 0 到 100。(如果您的范围不同,您将需要修改此代码,或者在发送到该方法时修改您的数据集为 0 到 100 之间。)
还有更多...
我们的雷达并不完美,因为它可能需要一个图例和一些文本信息围绕我们的雷达,以便我们知道每个条形代表什么。我们将让您像在前面的配方使用圆环图显示关系中所做的那样整理一个图例。
添加旋转的图例
为了解决这个问题并添加我们的文本,我们将重新访问我们的drawWeb
函数,通过该函数中的第一个循环,而不是更新 cos/sin 值来找到旋转,我们将只是旋转我们的画布,并在每次边缘集成我们的文本:
function drawWeb(context,chartInfo,radius){
chartInfo.stepSize = chartInfo.max/chartInfo.steps;
var hSteps = chartInfo.types.length;
var hStepSize = (Math.PI*2)/hSteps;
context.translate(wid/2,hei/2);
context.strokeStyle = "#000000";
context.textAlign="center";
for(var i=0; i<hSteps; i++){
context.moveTo(0,0); context.lineTo(Math.cos( radianOffset)*(radius+20),Math.sin( radianOffset)*(radius+20));
context.fillText(chartInfo.types[i],Math.cos( radianOffset)*(radius+30),Math.sin( radianOffset)*(radius+30));
context.rotate(hStepSize);
}
这里的逻辑要简单一些,因为我们每次只是旋转我们的画布,并且一遍又一遍地使用完全相同的代码,直到旋转完成一个完整的圆。
构建树状图
虚拟世界中有许多类型的树,尽管最直观的是家谱树。家谱树比基本数据树(如类继承树)更复杂,因为大多数情况下,类只有一个父类,而家谱树通常有两个。
我们将为 ActionScript 3.0 的显示对象构建一个继承树。
准备就绪
请注意,这个示例在 HTML5 中是尖端的。一个新功能,没有人真正知道是否会被采用的是 E4X。它已经被 Firefox 采用,但并非所有浏览器都实现了它(Flash 也完全支持)。
ECMAScript for XML(E4X)是一种编程语言扩展,它为 ECMAScript 添加了本机 XML 支持。它已经取代了 DOM 接口,并作为原语(如数字和布尔值)实现,使其更快速和更优化。
由于我们主要在本地工作,我们将直接在 JavaScript 中保存我们的 XML 文档,以避免沙盒安全问题。
为了帮助我们分隔元素,我们将在此示例中将我们的画布区域扩大(800 x 400)。好了,让我们开始实现使用 E4X 创建的树示例。
如何做...
执行以下步骤:
- 我们将首先创建包含我们类树的 XML 对象(请注意,这只适用于 Firefox 的最新版本,因为在撰写本书时):
var xml = <node name ="Display Object">
<node name="AVM1Mobie" />
<node name="Bitmap" />
<node name="InteractiveObject" >
<node name="DisplayObjectContainer">
<node name="Loader" />
<node name="Sprite" >
<node name="MovieClip"/>
</node>
<node name="Stage" />
</node>
<node name="SimpleButton" />
<node name="TextField" />
</node>
<node name="MorphShape" />
<node name="Shape" />
<node name="StaticText" />
<node name="Video" />
</node>;
- 然后创建我们的标准辅助和样式对象:
var wid;
var hei;
var style = {boxWidth:90,boxHeight:30, boxColor:"black",boxCopy:"white", boxSpace:4, lines:"black",lineSpace:30 };
- 我们将实现我们的
init
函数,然后调用drawTree
函数:
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
var context = can.getContext("2d");
context.textAlign = "center";
context.font = "6pt Arial";
drawTree(context,wid/2,20, xml );
}
- 现在是时候实现
drawTree
函数了(我们的递归函数)。
function drawTree(context,_x,_y,node){
context.fillStyle=style.boxColor;
context.fillRect(_x-style.boxWidth/2,_y-style.boxHeight/2,style.boxWidth,style.boxHeight);
context.fillStyle=style.boxCopy;
context.fillText(node.@name,_x,_y+8);
if(node.hasComplexContent()){
var nodes = node.node;
var totalWidthOfNewLayer = nodes.length()* style.boxWidth;
if(nodes.length()>1)totalWidthOfNewLayer += ( nodes.length()-1)* style.boxSpace;
var startXPoint = _x-totalWidthOfNewLayer/2 + style.boxWidth/2;
var currentY = _y+style.boxHeight/2;
context.beginPath();
context.strokeStyle ="#000000";
context.lineWidth=3;
context.moveTo(_x,currentY);
currentY+=style.lineSpace/2;
context.lineTo(_x,currentY);
context.moveTo(startXPoint,currentY);
context.lineTo(startXPoint+totalWidthOfNewLayer- style.boxWidth,currentY);
context.stroke();
for(var i=0; i<nodes.length();i++){
drawTree(context,startXPoint + i*(style.boxWidth + style.boxSpace) ,_y+50,nodes[i]);
}
}
}
塔达!我们刚刚创建了我们的第一个树。
它是如何工作的...
有关 E4X 工作原理的更多信息,我建议查看一些在线资源,如goo.gl/jLWYd
和goo.gl/dsHD4
。
让我们深入了解一下我们的递归drawTree
是如何工作的。createTree
的基本思想是创建当前焦点节点,并检查节点是否有子节点;如果有,将它们发送到drawTree
并让它们递归继续,直到所有子节点都创建完成。创建递归函数(调用自身的函数)时,最关键的一点是确保它不会无休止地进行下去,而我们的情景有一个非常明确定义的基于 XML 结构的结束,所以是安全的。
我们首先根据函数参数中发送的点值创建当前焦点节点:
context.fillStyle=style.boxColor;
context.fillRect(_x-style.boxWidth/2,_y-style.boxHeight/2,style.boxWidth,style.boxHeight);
context.fillStyle=style.boxCopy;
context.fillText(node.@name,_x,_y+8);
在这些行之后,事情开始变得真正有趣。如果我们的节点很复杂,我们将假设它有子节点,因为这是我们创建 XML 对象的基本规则;如果是这样,那么现在是时候为我们绘制子节点了:
if(node.hasComplexContent()){
我们首先绘制一个可视化条,以帮助我们查看当前元素的子元素,并在此过程中创建一些辅助变量:
var nodes = node.node;
var totalWidthOfNewLayer = nodes.length()* style.boxWidth;
if(nodes.length()>1)
totalWidthOfNewLayer += ( nodes.length()-1)* style.boxSpace;
var startXPoint = _x-totalWidthOfNewLayer/2 + style.boxWidth/2;
var currentY = _y+style.boxHeight/2;
context.beginPath();
context.strokeStyle ="#000000";
context.lineWidth=3;
context.moveTo(_x,currentY);
currentY+=style.lineSpace/2;
context.lineTo(_x,currentY);
context.moveTo(startXPoint,currentY);
context.lineTo(startXPoint+totalWidthOfNewLayer- style.boxWidth,currentY);
context.stroke();
在创建我们的轮廓辅助线之后,是时候循环遍历子节点并将它们发送到drawTree
以获得它们的新位置了:
for(var i=0; i<nodes.length();i++){
drawTree(context,startXPoint + i*(style.boxWidth + style.boxSpace) ,_y+50,nodes[i]);
}
}
这涵盖了所有的逻辑。在这个阶段,逻辑将为每个元素重新开始。
还有更多...
在理想的世界中,我们的树的工作现在应该已经完成了,但在现实世界的情况下,我们经常会遇到问题。如果我们足够玩弄我们当前的树,我们会发现视觉问题,比如如果一个子节点有多个子节点,它的子节点将重叠在其他树枝上。例如,如果我们更新我们的Loader
类以拥有两个新的子节点(这只是为了我们的示例而创建的两个虚拟类):
var xml = <node name ="Display Object">
<node name="AVM1Mobie" />
<node name="Bitmap" />
<node name="InteractiveObject" >
<node name="DisplayObjectContainer">
<node name="Loader">
<node name="SlideLoader"/>
<node name="ImageLoader"/>
</node>
<node name="Sprite" >
<node name="MovieClip"/>
<node name="MovieClip2"/>
</node>
<node name="Stage" />
</node>
<node name="SimpleButton" />
<node name="TextField" />
</node>
<node name="MorphShape" />
<node name="Shape" />
<node name="StaticText" />
<node name="Video" />
</node>;
如果您刷新浏览器(目前仅限 Firefox),您会发现我们的元素重叠在一起,因为我们没有考虑到具有子元素的选项。如果我们更深入地审查我们的代码,我们会发现在当前的逻辑格式中,没有办法解决这个问题,因为子元素的创建是分开进行的。我们需要想出一种方法来管理行,这样我们的元素就会知道它们即将重叠。
为了解决这个问题,我们需要使我们的递归函数更复杂,因为它需要跟踪其子元素的 x 位置,以便在重叠时进行偏移。请查看修改后的代码(更改用粗体标记):
function drawTree(context,_x,_y,node,nextChildX){
context.fillStyle=style.boxColor;
context.fillRect(_x-style.boxWidth/2,_y-style.boxHeight/2,style.boxWidth,style.boxHeight);
context.fillStyle=style.boxCopy;
context.fillText(node.@name,_x,_y+8);
if(node.hasComplexContent()){
var nodes = node.node;
var totalWidthOfNewLayer = nodes.length()* style.boxWidth;
if(nodes.length()>1)totalWidthOfNewLayer += ( nodes.length()-1)* style.boxSpace;
var startXPoint = _x-totalWidthOfNewLayer/2 + style.boxWidth/2;
var currentY = _y+style.boxHeight/2;
context.beginPath();
context.strokeStyle ="#000000";
context.lineWidth=3;
context.moveTo(_x,currentY);
if(nextChildX>startXPoint){
currentY+=style.lineSpace/4;
context.lineTo(_x,currentY);
context.lineTo(_x + (nextChildX-startXPoint),currentY);
currentY+=style.lineSpace/4;
context.lineTo(_x + (nextChildX-startXPoint),currentY);
startXPoint = nextChildX; // offset correction value
}else{
currentY+=style.lineSpace/2;
context.lineTo(_x,currentY);
}
context.moveTo(startXPoint,currentY);
context.lineTo(startXPoint+totalWidthOfNewLayer- style.boxWidth,currentY);
context.stroke();
var returnedNextChildX=0;
for(var i=0; i<nodes.length();i++){
returnedNextChildX = drawTree(context,startXPoint + i*(style.boxWidth + style.boxSpace) ,_y+50,nodes[i],returnedNextChildX);
}
return startXPoint + i*(style.boxWidth + style.boxSpace);
}
return 0;
}
哇,看起来很复杂——因为它确实很复杂!所以让我们分解这个逻辑。
这个想法很简单,但对于每个简单的想法来说,有时在实施后很难可视化。这个想法是,每当我们创建一个新的树元素时,如果它没有子元素,我们将返回0
,如果它有子元素,我们将为未来的子元素发送下一个空闲位置。我们还向函数添加了第四个参数,并且每次循环遍历子元素时都发送了该信息。这样每个子元素都知道上一个子元素离开的位置。如果无法计算出元素的实际位置,我们将根据偏移量绘制重定向线,并更新startXPoint
。深入研究一下这个(到目前为止,这是我在食谱中最喜欢的代码),这很有趣!
第五章:走出常规
在本章中,我们将涵盖:
-
通过漏斗(金字塔图表)
-
重新审视线条:使线状图表具有交互性
-
树状映射和递归
-
将用户交互添加到树状映射中
-
制作一个交互式点击计数器
介绍
我们已经涵盖了大多数标准图表的基础知识。在这个阶段,是时候让我们的图表变得更有创意了。从本章开始,我们将进入更具创意的、不常用的图表,并重新审视一些旧图表,将动态数据整合到它们中,或者改变它们的布局。
通过漏斗(金字塔图表)
很少见到动态创建的金字塔图表。在大多数情况下,它们是在设计和创意上进行完善,当它们到达网络时变成一个.jpg 文件,这正是我想以这个图表开始这一章的原因——它并不像听起来那么复杂。
金字塔图表本质上是一种让我们可视化数据变化的方式,这些数据本质上是定量的。它们在较低层和较高层之间有明确的关系。听起来很模糊,所以让我们通过一个例子来解释。
假设在某一年有 X 人完成了他们的第八年学校教育,如果我们跟随同一群人,四年后有多少人完成了他们的第十二年教育?好吧!我们无法知道答案,但我们知道的一件事是,它不可能超过最初的 X 人数。金字塔图表的概念正是这样一个数据体,随着时间或其他因素的变化,通过漏斗的数据越来越少。这是一个非常好的图表,可以比较教育水平、财务、政治参与等方面的情况。
准备工作
和往常一样,设置我们的 HTML 文件逻辑。如果需要关于如何启动 HTML 文件的复习,请回到第一章中的使用 2D 画布进行图形处理。
如何做...
除了我们标准的 HTML 准备工作之外,我们需要想出我们希望展示的数据来源。让我们开始建立我们的金字塔。直接进入 JS 文件,让我们开始吧。
- 对于我们的示例,我们将创建一个金字塔,以找出从第一章到第五章阅读本书的人中实际到达第五章的人数(这些数据是虚构的;我希望每个开始阅读的人都能到达那里!)。
var layers = [{label:"Completed Chapter 1", amount:23},
{label:"Completed Chapter 2", amount:15},
{label:"Completed Chapter 3", amount:11},
{label:"Completed Chapter 4", amount:7},
{label:"Completed Chapter 5", amount:3} ];
- 然后,提供一些图表和样式信息。
var chartInfo= {height:200, width:200};
var s = { outlinePadding:4,
barSize:16,
font:"12pt Verdana, sans-serif",
background:"eeeeee",
stroke:"cccccc",
text:"605050"
};
注意
注意,这是我们第一次区分我们希望画布的大小和图表(漏斗/三角形)的实际大小。另一个重要的事情是,为了使我们的示例在当前格式下工作,我们的三角形高度和宽度(底)必须相同。
- 定义一些全局辅助变量。
var wid;
var hei;
var totalPixels;
var totalData=0;
var pixelsPerData;
var currentTriangleHeight = chartInfo.height;
- 现在是时候创建我们的
init
函数了。这个函数将在另一个函数的帮助下承担大部分的工作。
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
totalPixels = (chartInfo.height * chartInfo.width) / 2;
for(var i in layers) totalData +=layers[i].amount;
pixelsPerData = totalPixels/totalData;
var context = can.getContext("2d");
context.fillStyle = s.background;
context.strokeStyle = s.stroke;
context.translate(wid/2,hei/2 - chartInfo.height/2);
context.moveTo(-chartInfo.width/2 , chartInfo.height);
context.lineTo(chartInfo.width/2,chartInfo.height);
context.lineTo(0,0);
context.lineTo(-chartInfo.width/2 , chartInfo.height);
for(i=0; i+1<layers.length; i++) findLine(context, layers[i].amount);
context.stroke();
}
- 我们的函数执行正常的设置并执行样式逻辑,然后创建一个三角形,然后找到正确的点(使用
findLine
函数)我们应该在哪里切割三角形:
function findLine(context,val){
var newHeight = currentTriangleHeight;
var pixels = pixelsPerData * val;
var lines = parseInt(pixels/newHeight); //rounded
pixels = lines*lines/2; //missing pixels
newHeight-=lines;
lines += parseInt(pixels/newHeight);
currentTriangleHeight-=lines;
context.moveTo(-currentTriangleHeight/2 , currentTriangleHeight);
context.lineTo(currentTriangleHeight/2,currentTriangleHeight);
}
这个函数根据当前线的数据找到我们三角形上的点。就是这样;现在是时候理解我们刚刚做了什么了。
它是如何工作的...
在init
函数中设置了线条的代码之后,我们准备开始考虑我们的三角形。首先,我们需要找出在我们的三角形内的总像素数。
totalPixels = (chartInfo.height * chartInfo.width) / 2;
这很容易,因为我们知道我们的高度和宽度,所以公式非常简单。下一个关键的数据点是总数据量。我们可以创建像素和数据之间的关系。
for(var i in layers) totalData +=layers[i].amount;
因此,我们循环遍历所有的数据层,并计算所有数据点的总和。在这个阶段,我们已经准备好找出实际像素的数量。每个数据元素相当于:
pixelsPerData = totalPixels/totalData;
设置了我们的描边和填充样式后,我们停下来考虑哪种最好的转换方式可以帮助我们构建我们的三角形。对于我们的三角形,我选择了顶边作为0,0
点,创建了三角形后:
context.translate(wid/2,hei/2 - chartInfo.height/2);
context.moveTo(-chartInfo.width/2 , chartInfo.height);
context.lineTo(chartInfo.width/2,chartInfo.height);
context.lineTo(0,0);
context.lineTo(-chartInfo.width/2 , chartInfo.height);
我们init
函数的最后两行调用layers
数组中每个元素的findLine
方法:
for(i=0; i+1<layers.length; i++) findLine(context, layers[i].amount);
context.stroke();
现在是时候深入了解findLine
函数是如何找到创建线的点的。这个想法非常简单。基本思想是尝试找出完成三角形中像素数量需要多少条线。由于我们不是在建立数学公式,我们不在乎它是否 100%准确,但它应该足够准确以在视觉上工作。
还有更多...
让我们开始向我们的调色板引入颜色。
var layers = [{label:"Completed Chapter 1", amount:23, style:"#B1DDF3"}, {label:"Completed Chapter 2", amount:15, style:"#FFDE89"},
{label:"Completed Chapter 3", amount:11, style:"#E3675C"},
{label:"Completed Chapter 4", amount:7, style:"#C2D985"},
{label:"Completed Chapter 5", amount:3, style:"#999999"}];
好了,我们完成了简单的部分。现在,是时候重新调整我们的逻辑了。
使findLine
更智能
为了能够创建一个封闭的形状,我们需要有一种改变绘制线的方向的方法,从右到左或从左到右,而不是让它总是朝一个方向。除此之外,我们现在正在使用moveTo
,因此永远无法创建一个封闭的形状。我们实际上想要的是移动我们的点并绘制一条线:
function findLine(context,val,isMove){
var newHeight = currentTriangleHeight;
var pixels = pixelsPerData * val;
var lines = parseInt(pixels/newHeight); //rounded
pixels = lines*lines/2; //missing pixels
newHeight-=lines;
lines += parseInt(pixels/newHeight);
currentTriangleHeight-=lines;
if(isMove){
context.moveTo(currentTriangleHeight/2,currentTriangleHeight);
context.lineTo(-currentTriangleHeight/2 , currentTriangleHeight);
}else{
context.lineTo(-currentTriangleHeight/2 , currentTriangleHeight);
context.lineTo(currentTriangleHeight/2,currentTriangleHeight);
}
}
我们下一个问题是,我们不想改变实际的三角形高度,因为我们将调用这个函数的次数比过去多。为了解决这个问题,我们需要提取一些逻辑。我们将返回创建的新线的数量,这样我们就可以从三角形中外部删除它们。这个操作使我们对视觉有更精细的控制(当我们加入文本时这一点将很重要)。
function findLine(context,val,isMove){
var newHeight = currentTriangleHeight;
var pixels = pixelsPerData * val;
var lines = parseInt(pixels/newHeight); //rounded
pixels = lines*lines/2; //missing pixels
newHeight-=lines;
lines += parseInt(pixels/newHeight);
newHeight = currentTriangleHeight-lines;
if(isMove){
context.moveTo(newHeight/2,newHeight);
context.lineTo(-newHeight/2 , newHeight);
}else{
context.lineTo(-newHeight/2 , newHeight);
context.lineTo(newHeight/2,newHeight);
}
return lines;
}
在这个阶段,我们的findLine
函数非常智能,能够帮助我们创建封闭的形状,而不需要控制更多(因为它不会改变任何全局数据)。
更改init
中的逻辑以创建形状
现在我们有了一个智能的findLine
函数,是时候重新编写与在init
函数中绘制线相关的逻辑了。
var secHeight = 0;
for(i=0;i<layers.length-1; i++){
context.beginPath();
findLine(context, 0,true);
secHeight = findLine(context, layers[i].amount);
currentTriangleHeight -= secHeight;
context.fillStyle = layers[i].style;
context.fill();
}
context.beginPath();
findLine(context, 0,true);
context.lineTo(0,0);
context.fillStyle = layers[i].style;
context.fill();
首先,我们在循环中绘制所有元素,减去最后一个(因为我们的最后一个元素实际上是一个三角形而不是一条线)。然后,为了帮助我们隐藏我们的数学不准确性,每次循环开始时我们都创建一个新路径,并首先调用我们的findLine
函数,没有新数据(在上次绘制线的地方绘制线,因为没有数据),然后绘制第二条线,这次使用真实的新数据。
我们对规则的例外是在循环之外创建的,在那里,我们只是手动绘制我们的形状,从最后一行开始,并将0,0
点添加到它上面,覆盖我们的三角形。
将文本添加到我们的图表中
这将很简单,因为我们在调整三角形大小之前已经得到了线数。我们可以使用这些数据来计算我们想要定位文本字段变量的位置,所以让我们做吧:
var secHeight = 0;
for(i=0;i<layers.length-1; i++){
context.beginPath();
findLine(context, 0,true);
secHeight = findLine(context, layers[i].amount);
currentTriangleHeight -= secHeight;
context.fillStyle = layers[i].style;
context.fill();
context.fillStyle = s.text;
context.fillText(layers[i].label, currentTriangleHeight/2 +secHeight/2, currentTriangleHeight+secHeight/2);
}
context.beginPath();
findLine(context, 0,true);
context.lineTo(0,0);
context.fillStyle = layers[i].style;
context.fill();
context.fillStyle = s.text;
context.fillText(layers[i].label, currentTriangleHeight/2 , currentTriangleHeight/2);
只需看一下在循环中绘制文本和在循环外绘制文本之间的区别。由于我们在循环中没有获取新的行数据,我们需要通过使用剩余三角形的总大小来改变点逻辑。
重温线条:使线图表交互
在这个食谱中,我们将回到我们早期的一个食谱,在第三章中创建基于笛卡尔的图表,并为其添加一些用户控制。这个控制使用户能够打开和关闭数据流。
准备工作
您需要采取的第一步是从第三章创建基于笛卡尔坐标的图表中获取源代码。我们将03.05.line-revamp.html
和03.05.line-revamp.js
重命名为05.02.line-revisit
。
现在我们的文件已经更新,添加我们的 HTML 文件——三个单选按钮组来表示三个数据源(2009 年、2010 年和 2011 年)。
<hr/>
2009 : <input type="radio" name="i2009" value="-1" /> off
<input type="radio" name="i2009" value="0" /> line
<input type="radio" name="i2009" value="1" select="1" /> full<br/>
2010 : <input type="radio" name="i2010" value="-1" /> off
<input type="radio" name="i2010" value="0" /> line
<input type="radio" name="i2010" value="1" select="1" /> full<br/>
2011 : <input type="radio" name="i2011" value="-1" /> off
<input type="radio" name="i2011" value="0" /> line
<input type="radio" name="i2011" value="1" select="1" /> full<br/>
请注意,我已经为每个单选按钮组添加了“i”以表示年份,并将可能的值设置为-1
、0
或1
。
如何做...
执行以下步骤:
- 创建一些常量(不会更改的变量),并设置以下三行,现在默认值已经分配:
var HIDE_ELEMENT = -1;
var LINE_ELEMENT = 0;
var FILL_ELEMENT = 1;
var elementStatus={ i2009:FILL_ELEMENT,
i2010:FILL_ELEMENT,
i2011:FILL_ELEMENT};
- 是时候将创建图表的逻辑移到一个单独的函数中。在初始化画布之后的所有内容都将被移出。
var context;
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
context = can.getContext("2d");
drawChart();
}
- 更新单选框以突出显示当前选定的内容,并为所有单选按钮添加
onchange
事件。
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
context = can.getContext("2d");
drawChart();
var radios ;
for(var id in elementStatus){
radios = document.getElementsByName(id);
for (var rid in radios){
radios[rid].onchange = onChangedRadio;
if(radios[rid].value == elementStatus[id] )
radios[rid].checked = true;
}
}
}
- 在我们的
drawChart
函数中进行一些更新。我们的目标是将新的控制器elementStatus
纳入线条的绘制中。
function drawChart(){
context.lineWidth = 1;
context.fillStyle = "#eeeeee";
context.strokeStyle = "#999999";
context.fillRect(0,0,wid,hei);
context.font = "10pt Verdana, sans-serif";
context.fillStyle = "#999999";
context.moveTo(CHART_PADDING,CHART_PADDING);
context.rect(CHART_PADDING,CHART_PADDING,wid-CHART_PADDING*2,hei-CHART_PADDING*2);
context.stroke();
context.strokeStyle = "#cccccc";
fillChart(context,chartInfo);
if(elementStatus.i2011>-1) addLine(context,formatData(a2011, "/2011","#B1DDF3"),"#B1DDF3",elementStatus.i2011==1);
if(elementStatus.i2010>-1) addLine(context,formatData(a2010, "/2010","#FFDE89"),"#FFDE89",elementStatus.i2010==1);
if(elementStatus.i2009>-1) addLine(context,formatData(a2009, "/2009","#E3675C"),"#E3675C",elementStatus.i2009==1);
}
- 最后但并非最不重要的是,让我们将逻辑添加到我们的
onChangedRadio
函数中。
function onChangedRadio(e){
elementStatus[e.target.name] = e.target.value;
context.clearRect(0,0,wid,hei);
context.beginPath();
drawChart();
}
就是这样!我们刚刚在图表中添加了用户交互。
它是如何工作的...
我们没有提前计划在此图表上进行用户交互。因此,我们需要重新审视它以更改一些逻辑。当 Canvas 绘制某物时,就是这样,它将永远存在!我们不能只删除一个对象,因为 Canvas 中没有对象,因此我们需要一种按需重新绘制的方法。为了实现这一点,我们需要从init
函数中提取所有绘图逻辑,并创建drawChart
函数。除了在函数末尾添加我们的逻辑之外,我们还需要添加函数的开始部分:
context.lineWidth = 1;
尽管我们最初计算出用作背景宽度的默认值,在第二次重绘中,我们的画布仍然会保留其上次的大小(在我们的情况下可能是3
),因此我们将其重置为原始值。
我们使用一个名为elementStatus
的对象来存储图表上每条线的当前状态。它可以存储的值如下:
-
-1
:不绘制 -
0
:绘制无填充的线 -
1
:绘制填充
因此,我们在函数末尾添加以下逻辑:
if(elementStatus.i2011>-1) addLine(context,formatData(a2011, "/2011","#B1DDF3"),"#B1DDF3",elementStatus.i2011==1);
由于逻辑重复三次,让我们只关注其中一个。如果愿意,我们可以使用我们的常量变量使逻辑更容易查看。
if(elementStatus.i2011!=HIDE_ELEMENT)
addLine(context,formatData(a2011, "/2011","#B1DDF3"),"#B1DDF3",elementStatus.i2011==FILL_ELEMENT);
逻辑分解为第一个if
语句,测试我们的内容是否应该隐藏。如果我们确定应该添加这行,我们通过将当前值与FILL_ELEMENT
进行比较的结果发送到填充/线参数中来绘制它,根据此操作的结果有两种变化。
还有更多...
不幸的是,因为我们没有使用任何开源库,内置的 HTML 功能不允许我们为单选按钮组设置事件,因此我们需要找到它们并使用我们在elementStatus
控制器中存储的 ID 为它们添加onchange
事件。
var radios ;
for(var id in elementStatus){
radios = document.getElementsByName(id);
for (var rid in radios){
radios[rid].onchange = onChangedRadio;
if(radios[rid].value == elementStatus[id] ) radios[rid].checked = true;
}
}
注意高亮显示的代码。在这里,我们正在检查当前单选按钮的值是否与elementStatus
中的元素值匹配。如果是,这意味着单选按钮将被选中。
分解 onChangedRadio 的逻辑
让我们再来看看这个函数中的逻辑:
elementStatus[e.target.name] = e.target.value;
我们要做的第一件事是将新选择的值保存到我们的elementStatus
控制器中。
context.clearRect(0,0,wid,hei);
接着我们清空画布上的所有内容。
context.beginPath();
接下来,清空并开始一个新路径。
drawChart();
然后开始重新绘制所有内容,我们在elementStatus
中的新参数将验证正确的内容将被绘制。
另请参阅
- 第三章创建基于笛卡尔坐标的图表中的构建线图配方
树状映射和递归
树状映射使我们能够从鸟瞰视角深入了解数据。与比较图表相反——例如我们到目前为止创建的大多数图表——树状映射将树状结构的数据显示为一组嵌套的矩形,使我们能够可视化它们的数量特性和关系。
让我们从仅展示一级信息的树状映射开始。
准备工作
我们将从世界上的人数开始我们的应用程序,以百万为单位,按大陆划分(基于 2011 年的公共数据)。
var chartData = [
{name: "Asia", value:4216},
{name: "Africa",value:1051},
{name: "The Americas and the Caribbean", value:942},
{name: "Europe", value:740},
{name: "Oceania", value:37}
];
我们将在我们的示例中稍后更新这个数据源,所以请记住这个数据集是临时的。
如何做...
我们将从创建一个简单的、工作的、平面树状图开始。让我们直接开始,找出创建树状图所涉及的步骤:
- 让我们在数据集的顶部添加一些辅助变量。
var wid;
var hei;
var context;
var total=0;
- 创建
init
函数。
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
context = can.getContext("2d");
for(var item in chartData) total += chartData[item].value;
context.fillRect(0,0,wid,hei);
context.fillStyle = "RGB(255,255,255)";
context.fillRect(5,5,wid-10,hei-10);
context.translate(5,5);
wid-=10;
hei-=10;
drawTreeMap(chartData);
}
- 创建函数
drawTreeMap
。
function drawTreeMap(infoArray){
var percent=0;
var cx=0;
var rollingPercent = 0;
for(var i=0; i<infoArray.length; i++){
percent = infoArray[i].value/total;
rollingPercent +=percent
context.fillStyle = formatColorObject(getRandomColor(255));
context.fillRect(cx,0 ,wid*percent,hei);
cx+=wid*percent;
if(rollingPercent > 0.7) break;
}
var leftOverPercent = 1-rollingPercent;
var leftOverWidth = wid*leftOverPercent;
var cy=0;
for(i=i+1; i<infoArray.length; i++){
percent = (infoArray[i].value/total)/leftOverPercent;
context.fillStyle = formatColorObject(getRandomColor(255));
context.fillRect(cx,cy ,leftOverWidth,hei*percent);
cy+=hei*percent;
}
}
- 创建一些格式化函数来帮助我们为我们的树状映射块创建一个随机颜色。
function formatColorObject(o){
return "rgb("+o.r+","+o.g+","+o.b+")";
}
function getRandomColor(val){
return {r:getRandomInt(255),g:getRandomInt(255),b:getRandomInt(255)};
}
function getRandomInt(val){
return parseInt(Math.random()*val)+1
}
在创建这么多格式化函数时有点过度,它们的主要目标是在我们准备进行下一步时帮助我们——在我们的数据中创建更多深度(有关更多细节,请参阅本食谱中的还有更多...部分)。
它是如何工作的...
让我们从最初的想法开始。我们的目标是创建一个地图,展示我们矩形区域内更大的体积区域,并在一侧留下一条条带以展示较小的区域。所以,让我们从我们的init
函数开始。我们的基本入门工作之外的第一个任务是计算实际总数。我们通过循环遍历我们的数据源来做到这一点,因此:
for(var item in chartData) total += chartData[item].value;
我们继续设计一些东西,并且让我们的工作区比总画布大小小 10 像素。
CONTEXT.FILLRECT(0,0,WID,HEI);
CONTEXT.FILLSTYLE = "RGB(255,255,255)";
CONTEXT.FILLRECT(5,5,WID-10,HEI-10);
CONTEXT.TRANSLATE(5,5);
WID-=10;
HEI-=10;
drawTreeMap(chartData);
是时候来看看我们的drawTreeMap
函数是如何工作的了。首先要注意的是,我们发送一个数组而不是直接使用我们的数据源。我们这样做是因为我们希望这个函数在我们开始构建这种可视化类型的内部深度时可以被重复使用。
function drawTreeMap(infoArray){...}
我们的函数从几个辅助变量开始(percent
变量将存储循环中的当前percent
值)。我们的矩形的cx
(当前 x)位置和rollingPercent
将跟踪我们的总图表完成了多少。
var percent=0;
var cx=0;
var rollingPercent = 0;
是时候开始循环遍历我们的数据并绘制出矩形了。
for(var i=0; i<infoArray.length; i++){
percent = infoArray[i].value/total;
rollingPercent +=percent
context.fillStyle =
formatColorObject(getRandomColor(255));
context.fillRect(cx,0 ,wid*percent,hei);
cx+=wid*percent;
在我们完成第一个循环之前,我们将测试它,看看我们何时越过我们的阈值(欢迎您调整该值)。当我们达到它时,我们需要停止循环,这样我们就可以开始按高度而不是宽度绘制我们的矩形。
if(rollingPercent > 0.7) break;
}
在我们开始处理我们的框之前,它们占据了全部剩余的宽度并扩展到高度,我们需要一些辅助变量。
var leftOverPercent = 1-rollingPercent;
var leftOverWidth = wid*leftOverPercent;
var cy=0;
从现在开始,我们需要根据剩余空间的大小计算每个元素,我们将计算值(leftOverPercent
),然后我们将提取我们形状的剩余宽度,并启动一个新的cy
变量来存储当前的 y 位置。
for(i=i+1; i<infoArray.length; i++){
percent = (infoArray[i].value/total)/leftOverPercent;
context.fillStyle = formatColorObject(getRandomColor(255));
context.fillRect(cx,cy ,leftOverWidth,hei*percent);
cy+=hei*percent;
}
我们从比我们离开的值高一个值开始我们的循环(因为我们在之前的循环中打破了它之前,我们没有机会更新它的值并绘制到我们剩余区域的高度。
请注意,在两个循环中我们都使用了formatColorObject
和getRandomColor
。这些函数的分解是为了让我们在下一部分中更容易操纵返回的颜色。
还有更多...
为了使我们的图表真正具有额外的功能,我们需要一种方法来使它能够以至少第二个较低级别的数据显示数据的方式。为此,我们将重新审视我们的数据源并对其进行重新编辑:
var chartData = [
{name: "Asia", data:[
{name: "South Central",total:1800},
{name: "East",total:1588},
{name: "South East",total:602},
{name: "Western",total:238},
{name: "Northern",total:143}
]},
{name: "Africa",total:1051},
{name: "The Americas and the Caribbean", data:[
{name: "South America",total:396},
{name: "North America",total:346},
{name: "Central America",total:158},
{name: "Caribbean",total:42}
]},
{name: "Europe", total:740},
{name: "Oceania", total:37}
];
现在我们有了世界上两个地区的更深入的子地区的视图。是时候修改我们的代码,使其能够再次处理这些新数据了。
更新init
函数——重新计算总数
在init
函数中,我们需要执行的第一步是用一个新的循环替换当前的总循环,这个新循环可以深入到元素中计算真正的总数。
var val;
var i;
for(var item in chartData) {
val = chartData[item];
if(!val.total && val.data){
val.total = 0;
for( i=0; i<val.data.length; i++)
val.total+=val.data[i].total;
}
total += val.total;
}
实质上,我们正在检查是否没有总数,以及是否有数据源。如果是这样,我们就开始一个新的循环来计算我们元素的实际总数——现在您可以尝试将这个逻辑变成一个递归函数(这样您就可以有更多层的数据)。
接下来,我们将更改drawTreeMap
并准备将其变成一个递归函数。为了实现这一点,我们需要从中提取全局变量,并将它们作为函数的参数发送。
drawTreeMap(chartData,wid,hei,0,0,total);
将 drawTreeMap 转换为递归函数
让我们更新我们的函数以启用递归操作。我们首先添加一个额外的新参数来捕获最新的颜色。
function drawTreeMap(infoArray,wid,hei,x,y,total,clr){
var percent=0;
var cx=x ;
var cy=y;
var pad = 0;
var pad2 = 0;
var rollingPercent = 0;
var keepColor = false;
if(clr){ //keep color and make darker
keepColor = true;
clr.r = parseInt(clr.r *.9);
clr.g = parseInt(clr.g *.9);
clr.b = parseInt(clr.b *.9);
pad = PAD*2;
pad2 = PAD2*2;
}
如果我们传递了一个clr
参数,我们需要在所有新创建的矩形中保持该颜色,并且我们需要在形状周围添加一些填充,以便更容易看到它们。我们还通过减去其所有 RGA 属性的 10%使颜色变暗一点。
下一步是添加填充和递归逻辑。
for(var i=0; i<infoArray.length; i++){
percent = infoArray[i].total/total;
rollingPercent +=percent
if(!keepColor){
clr = getRandomColor(255);
}
context.fillStyle = formatColorObject(clr);
context.fillRect(cx+pad ,cy+pad ,wid*percent - pad2,hei-pad2);
context.strokeRect(cx+pad ,cy+pad ,wid*percent - pad2,hei-pad2);
if(infoArray[i].data){
drawTreeMap(infoArray[i].data,parseInt(wid*percent - PAD2),hei - PAD2,cx+ PAD,cy + PAD,infoArray[i].total,clr);
}
cx+=wid*percent;
if(rollingPercent > 0.7) break;
}
同样的逻辑也在第二个循环中实现了(查看源文件以了解详情)。
将数据和总数转换为递归数据
让我们首先更新我们的树数据,使其真正递归(完整数据集请参考源代码)。
...
{name: "Asia", data:[
{name: "South Central",total:1800},
{name: "East",total:1588},
{name: "South East",total:602},
{name: "Western",total:238},
{name: "Northern",data:[{name: "1",data:[
{name: "2",total:30},
{name: "2",total:30}
]},
{name: "2",total:53},
{name: "2",total:30}
]} ...
现在,我们有一个具有四个以上信息级别的树状图,我们可以重新审视我们的代码,并解决我们最后的问题,验证我们的总数在所有级别上始终是最新的。为了解决这个问题,我们将计算总数的逻辑提取到一个新函数中,并更新init
函数中的total
行。
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
context = can.getContext("2d");
total = calculateTotal(chartData); //recursive function
...
是时候创建这个神奇的(递归)函数了。
function calculateTotal(chartData){
var total =0;
var val;
var i;
for(var item in chartData) {
val = chartData[item];
if(!val.total && val.data)
val.total = calculateTotal(val.data);
total += val.total;
}
return total;
}
逻辑与以前非常相似,唯一的区别是所有数据条目都是函数内部的,并且每次需要处理另一层数据时,它都会以递归的方式重新发送到同一个函数中,直到所有数据都解析完毕——直到它返回总数。
另请参阅
- 将用户交互添加到树映射教程
将用户交互添加到树映射
到目前为止,我们在示例中限制了用户的交互。在我们最后的一个示例中,我们以一种受控的方式添加和删除图表元素;在这个示例中,我们将使用户能够深入图表并通过创建一个真正无尽的体验来查看更多细节(如果我们只有无尽的数据可以挖掘)。
在下图中,左侧是初始状态,右侧是用户点击一次后的状态(图表重新绘制以展示被点击的区域)。
考虑当用户点击图表时的情况(例如,点击左侧矩形后生成的下一张图片——树状图将更新并放大到该区域)。
准备工作
为了正确使用这个示例,您需要从我们上一个教程树映射和递归开始,并调整它以适应这个示例。
如何做...
这是我们的第一个示例,我们使我们的画布区域具有交互性。在接下来的几步中,我们将从上一个示例中添加一些逻辑到我们的教程中,以使用户能够放大或缩小它:
- 新增一个全局变量,
var currentDataset;
- 存储发送到树映射函数的当前数据。
currentDataset = chartData;
drawTreeMap(chartData,wid,hei,0,0,total);
- 在我们的画布区域添加一个
click
事件。
can.addEventListener('click', onTreeClicked, false);
- 创建
onTreeClick
事件。
function onTreeClick(e) {
var box;
for(var item in currentDataset){
if(currentDataset[item].data){
box = currentDataset[item].box;
if(e.x>= box.x && e.y>= box.y &&
e.x<= box.x2 && e.y<= box.y2){
context.clearRect(0,0,wid,hei);
drawTreeMap(currentDataset[item].data,wid,hei,0,0,currentDataset[item].total);
currentDataset = currentDataset[item].data;
break;
}
}
}
}
- 在
drawTreemap
中两次绘制矩形——第一次在第一个循环中,第二次在第二个循环中。让我们用一个外部函数来替换它——替换绘制矩形的for
循环行:
drawRect(cx+pad ,cy+pad ,wid*percent – pad2,hei-pad2,infoArray[i]);
- 是时候创建矩形函数了。
function drawRect(x,y,wid,hei,dataSource){
context.fillRect(x,y,wid,hei);
context.strokeRect(x,y,wid,hei);
dataSource.box = {x:x,y:y,x2:x+wid,y2:y+hei};
}
就是这样!我们有一个完全功能的、深层次的、与用户无限交互的图表(只取决于我们有多少数据)。
它是如何工作的...
Canvas 元素目前不支持与对象交互的智能方式。由于画布中没有对象,一旦创建元素,它就会变成位图,并且其信息将从内存中删除。幸运的是,我们的示例是由矩形构成的,这样就更容易识别我们点击的元素。我们需要在内存中存储我们绘制的每个元素的当前框位置。
因此,我们逻辑的第一步是我们在步骤 6 中做的最后一件事。我们想捕获构成我们矩形的点,这样在我们的click
事件中,我们就可以弄清楚我们的点与矩形的关系:
function onTreeClick(e) {
var box;
for(var item in currentDataset){
if(currentDataset[item].data){
我们循环遍历我们的数据源(当前的数据源),并检查我们当前所在的元素是否有数据源(即子元素);如果有,我们继续,如果没有,我们将跳过下一个元素来测试它。
现在我们知道我们的元素有子元素,我们准备看看我们的点是否在元素的范围内。
box = currentDataset[item].box;
if(e.x>= box.x && e.y>= box.y &&
e.x<= box.x2 && e.y<= box.y2){
如果是,我们准备重新绘制树状图,并用当前更深的数据集替换我们当前的数据集。
context.clearRect(0,0,wid,hei);
drawTreeMap(currentDataset[item].data,wid,hei,0,0,currentDataset[item].total);
currentDataset = currentDataset[item].data;
break;
然后我们退出循环(使用break
语句)。请注意,我们做的最后一件事是更新currentDataset
,因为我们仍然需要从中获取信息以将总数据发送到drawTreeMap
。当我们使用完它后,我们准备用新的数据集覆盖它(之前的子元素变成了下一轮的主要参与者)。
还有更多...
目前,没有办法在不刷新一切的情况下返回。因此,让我们添加到我们的逻辑中,如果用户点击没有子元素的元素,我们将恢复到原始地图。
回到主要的树状图
让我们将以下代码添加到click
事件中:
function onTreeClick(e) {
var box;
for(var item in currentDataset){
if(currentDataset[item].data){
box = currentDataset[item].box;
if(e.x>= box.x && e.y>= box.y &&
e.x<= box.x2 && e.y<= box.y2){
context.clearRect(0,0,wid,hei);
drawTreeMap(currentDataset[item].data,wid,hei,0,0,currentDataset[item].total);
currentDataset = currentDataset[item].data;
break;
}
}else{
currentDataset = chartData;
drawTreeMap(chartData,wid,hei,0,0,total);
}
}
}
太棒了!我们刚刚完成了为用户创建一个完全互动的体验,现在轮到你来让它看起来更好一些了。添加一些悬停标签和所有可视化效果,这将使您的图表在视觉上更加愉悦,并有助于理解。
创建一个交互式点击计量器
在下一个示例中,我们将专注于客户端编程的一个更强大的特性——与用户交互的能力和动态更新数据的能力。为了简单起见,让我们重新访问一个旧图表——第三章中的条形图,创建基于笛卡尔坐标的图表——并集成一个计数器,它将计算用户在任何给定秒内点击 HTML 文档的次数,并相应地更新图表。
如何做...
大部分步骤都会很熟悉,如果你曾经在第三章中的条形图上工作过,创建基于笛卡尔坐标的图表。因此,让我们运行它们,然后专注于新的逻辑:
- 让我们创建一些辅助变量。
var currentObject = {label:1,
value:0,
style:"rgba(241, 178, 225, .5)"};
var colorOptions = ["rgba(241, 178, 225, 1)","#B1DDF3","#FFDE89","#E3675C","#C2D985"];
var data = [];
var context;
var wid;
var hei;
- 接下来是我们的
init
函数。
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
context = can.getContext("2d");
document.addEventListener("click",onClick);
interval = setInterval(onTimeReset,1000);
refreshChart();
}
- 现在是时候创建
onTimeReset
函数了。
function onTimeReset(){
if(currentObject.value){
data.push(currentObject);
if(data.length>25) data = data.slice(1);
refreshChart();
}
currentObject = {label:currentObject.label+1, value:0, style: colorOptions[currentObject.label%5]};
}
- 下一步是创建
onClick
监听器。
function onClick(e){
currentObject.value++;
refreshChart();
}
- 现在创建
refreshChart
函数。
function refreshChart(){
var newData = data.slice(0);
newData.push(currentObject);
drawChart(newData);
}
- 最后但并非最不重要的是,让我们创建
drawChart
(它的大部分逻辑与第三章中讨论的init
函数相同,创建基于笛卡尔坐标的图表)。
function drawChart(data){
context.fillStyle = "#eeeeee";
context.strokeStyle = "#999999";
context.fillRect(0,0,wid,hei);
var CHART_PADDING = 20;
context.font = "12pt Verdana, sans-serif";
context.fillStyle = "#999999";
context.moveTo(CHART_PADDING,CHART_PADDING);
context.lineTo(CHART_PADDING,hei-CHART_PADDING);
context.lineTo(wid-CHART_PADDING,hei-CHART_PADDING);
var stepSize = (hei - CHART_PADDING*2)/10;
for(var i=0; i<10; i++){
context.moveTo(CHART_PADDING, CHART_PADDING + i*stepSize);
context.lineTo(CHART_PADDING*1.3,CHART_PADDING + i*stepSize);
context.fillText(10-i, CHART_PADDING*1.5, CHART_PADDING + i* stepSize + 6);
}
context.stroke();
var elementWidth =(wid-CHART_PADDING*2)/ data.length;
context.textAlign = "center";
for(i=0; i<data.length; i++){
context.fillStyle = data[i].style;
context.fillRect(CHART_PADDING +elementWidth*i ,hei-CHART_PADDING - data[i].value*stepSize,elementWidth,data[i].value*stepSize);
context.fillStyle = "rgba(255, 255, 225, 0.8)";
context.fillText(data[i].label, CHART_PADDING +elementWidth*(i+.5), hei-CHART_PADDING*1.5);
}
}
就是这样!我们有一个交互式图表,它将每秒更新一次,取决于您在 1 秒内点击鼠标的次数——我假设没有人可以在一秒内点击超过 10 次,但我已经成功做到了(使用两只手)。
它是如何工作的...
让我们专注于第三章中数据变量的分解,创建基于笛卡尔的图表。我们之前在数据对象中准备好了所有数据。这一次,我们保持数据对象为空,而是将一个数据行放在一个单独的变量中。
var currentObject = {label:1,
value:0,
style:"rgba(241, 178, 225, .5)"};
var data = [];
每次用户点击时,我们都会更新currentObject
的计数器,并刷新图表,从而使用户体验更加动态和实时。
function onClick(e){
currentObject.value++;
refreshChart();
}
我们在init
函数中设置间隔如下:
interval = setInterval(onTimeReset,1000);
每秒钟,函数都会检查用户在那段时间内是否有任何点击,如果有,它会确保我们将currentObject
推入数据集中。如果数据集的大小大于25
,我们就会将其中的第一项删除,并刷新图表。无论我们创建什么,一个新的空对象都会被标记上显示当前时间的新标签。
function onTimeReset(){
if(currentObject.value){
data.push(currentObject);
if(data.length>25) data = data.slice(1);
refreshChart();
}
currentObject = {label:currentObject.label+1, value:0, style: colorOptions[currentObject.label%5]};
}
在我们结束这个示例之前,你应该看一下最后一件事:
function refreshChart(){
var newData = data.slice(0);
newData.push(currentObject);
drawChart(newData);
}
我们逻辑的这一部分真的是让我们能够在用户点击按钮时更新数据的关键。我们想要有一个新的数组来存储新数据,但我们不希望当前元素受到影响,所以我们通过将新数据对象添加到其中来复制数据源,然后将其发送到创建图表。
第六章:将静态事物变得生动起来
在本章中,我们将涵盖以下主题:
-
堆叠图形层
-
转向面向对象的视角
-
动画独立层
-
添加一个交互式图例
-
创建一个上下文感知的图例
介绍
到目前为止,保持组织和清洁的重要性并不像完成我们的项目那样重要,因为我们的项目相对较小。本章将通过首先使一切都变得动态,然后创建一个更面向对象的程序,使我们更容易分离任务并减少我们的代码量,为我们带来一些新的习惯。经过所有这些辛苦的工作,我们将重新审视我们的应用程序,并开始添加额外的逻辑,以使我们的应用程序逐层动画化。
本章是重构实践的一个很好的资源。在本章的前半部分,我们将专注于改进我们的代码结构,以使我们能够在本章的后半部分拥有我们需要的控制水平。
堆叠图形层
在我们可以在画布上进行任何真正的动画之前,我们真的需要重新思考在一个画布层上构建一切的概念。一旦画布元素被绘制,就非常难以对其进行微小的细微变化,比如特定元素的淡入效果。我们将重新访问我们的一个著名图表,柱状图,我们在早期章节中多次玩耍和增强。在本章中,我们的目标将是打破逻辑并使其更加模块化。在这个配方中,我们将分离层。每一层都将在我们准备好进行动画时给我们更多的控制。
准备工作
首先从上一章中获取最新的文件:05.02.line-revisit.html
和05.02.line-revisit.js
。
如何做...
对 HTML 文件进行以下更改:
- 更新 HTML 文件以包含更多的画布元素(每个绘制线条一个):
<body onLoad="init();" style="background:#fafafa">
<h1>Users Changed between within a year</h1>
<div class="graphicLayers" >
<canvas id="base" class="canvasLayer" width="550" height="400"> </canvas>
<canvas id="i2011" class="canvasLayer" width="550" height="400"> </canvas>
<canvas id="i2010" class="canvasLayer" width="550" height="400"> </canvas>
<canvas id="i2009" class="canvasLayer" width="550" height="400"> </canvas>
</div>
<div class="controllers">
2009 : <input type="radio" name="i2009" value="-1" /> off
<input type="radio" name="i2009" value="0" /> line
<input type="radio" name="i2009" value="1" select="1" /> full ||
2010 : <input type="radio" name="i2010" value="-1" /> off
<input type="radio" name="i2010" value="0" /> line
<input type="radio" name="i2010" value="1" select="1" /> full ||
2011 : <input type="radio" name="i2011" value="-1" /> off
<input type="radio" name="i2011" value="0" /> line
<input type="radio" name="i2011" value="1" select="1" /> full
</div>
</body>
</html>
- 添加一个 CSS 脚本,使层叠起来:
<head>
<title>Line Chart</title>
<meta charset="utf-8" />
<style>
.graphicLayers {
position: relative;
left:100px
}
.controllers {
position: relative;
left:100px;
top:400px;
}
.canvasLayer{
position: absolute;
left: 0;
top: 0;
}
</style>
<script src="img/06.01.layers.js"></script>
</head>
让我们进入 JavaScript 文件进行更新。
- 添加一个
window.onload
回调函数(在代码片段中突出显示的更改):
window.onload = init;
function init(){
- 从全局范围中删除变量
context
(删除高亮显示的代码片段):
var CHART_PADDING = 20;
var wid;
var hei;
var context;
- 将所有柱线信息合并到一个对象中,以便更容易控制(删除所有高亮显示的代码片段):
var a2011 = [38,65,85,111,131,160,187,180,205,146,64,212];
var a2010 = [212,146,205,180,187,131,291,42,98,61,74,69];
var a2009 = [17,46,75,60,97,131,71,52,38,21,84,39];
var chartInfo= { y:{min:0, max:300, steps:5,label:"users"},
x:{min:1, max:12, steps:11,label:"months"}
};
var HIDE_ELEMENT = -1;
var LINE_ELEMENT = 0;
var FILL_ELEMENT = 1;
var elementStatus={i2009:FILL_ELEMENT,i2010:FILL_ELEMENT,i2011:FILL_ELEMENT};
var barData = {
i2009:{
status: FILL_ELEMENT,
style: "#E3675C",
label: "/2009",
data:[17,46,75,60,97,131,71,52,38,21,84,39]
},
i2010:{
status: FILL_ELEMENT,
style: "#FFDE89",
label: "/2010",
data:[212,146,205,180,187,131,291,42,98,61,74,69]
},
i2011:{
status: FILL_ELEMENT,
style: "#B1DDF3",
label: "/2011",
data:[38,65,85,111,131,160,187,180,205,146,64,212]
}
};
- 从
init
函数中删除所有画布逻辑,并将其添加到drawChart
函数中:
function init(){
var can = document.getElementById("bar");
wid = can.width;
hei = can.height;
context = can.getContext("2d");
drawChart();
var radios ;
for(var id in elementStatus){
radios = document.getElementsByName(id);
for (var rid in radios){
radios[rid].onchange = onChangedRadio;
if(radios[rid].value == elementStatus[id] ) radios[rid].checked = true;
}
}
}
function drawChart(){
var can = document.getElementById("base");
wid = can.width;
hei = can.height;
var context = can.getContext("2d");
...
- 在
init
函数中更新对新数据对象的引用:
function init(){
drawChart();
var radios ;
for(var id in barData){
radios = document.getElementsByName(id);
for (var rid in radios){
radios[rid].onchange = onChangedRadio;
if(radios[rid].value == barData[id].status ) radios[rid].checked = true;
}
}
}
- 在
drawChart
函数中,将线条创建的逻辑提取到一个外部函数中(删除高亮显示的代码片段):
if(elementStatus.i2011>-1) addLine(context,formatData(a2011, "/2011","#B1DDF3"),"#B1DDF3",elementStatus.i2011==1);
if(elementStatus.i2010>-1) addLine(context,formatData(a2010, "/2010","#FFDE89"),"#FFDE89",elementStatus.i2010==1);
if(elementStatus.i2009>-1) addLine(context,formatData(a2009, "/2009","#E3675C"),"#E3675C",elementStatus.i2009==1);
changeLineView("i2011",barData.i2011.status);
changeLineView("i2010",barData.i2010.status);
changeLineView("i2009",barData.i2009.status);
- 更改
onChangedRadio
回调函数中的逻辑。让它触发对changeLineView
函数的调用(我们将在下面创建该函数):
function onChangedRadio(e){
changeLineView(e.target.name,e.target.value);
}
- 创建函数
changeLineView
:
function changeLineView(id,value){
barData[id].status = value;
var dataSource = barData[id];
can = document.getElementById(id);
context = can.getContext("2d");
context.clearRect(0,0,wid,hei);
if( dataSource.status!=HIDE_ELEMENT){
context.beginPath();
addLine(context,formatData(dataSource.data, dataSource.label,dataSource.style),dataSource.style,dataSource.status==1);
}
}
在所有这些更改之后运行 HTML 文件,你应该看到与我们在开始所有这些更改之前看到的完全相同的东西。如果是这样,那么你就处于一个很好的位置。然而,我们目前还看不到任何变化。
工作原理...
这个配方的核心是我们的 HTML 文件,它使我们能够将画布元素层叠在彼此之上,由于我们的画布默认是透明的,我们可以看到它下面的元素。在我们的画布上叠加了四个层之后,是时候将我们的背景与线条分开了,因此我们希望将所有的图表背景信息都放在基础画布中:
var can = document.getElementById("base");
对于每个线条层,我们使用一个预先配置的画布元素,它已经设置好:
changeLineView("i2011",barData.i2011.status);
changeLineView("i2010",barData.i2010.status);
changeLineView("i2009",barData.i2009.status);
第一个参数既是我们画布的 ID,也是我们在存储线条信息的新对象中使用的键(以保持我们的代码简洁):
var barData = {
i2009:{...},
i2010:{...},
i2011:{...}
};
在这个数据对象中,我们有与画布中完全相同数量的元素,名称也完全相同。这样我们就可以非常容易地获取信息,而不需要使用额外的变量或条件。这与创建/更新线条的逻辑相关:
function changeLineView(id,value){
barData[id].status = value;
var dataSource = barData[id];
can = document.getElementById(id);
context = can.getContext("2d");
context.clearRect(0,0,wid,hei);
if( dataSource.status!=HIDE_ELEMENT){
context.beginPath();
addLine(context,formatData(dataSource.data, dataSource.label,dataSource.style),dataSource.style,dataSource.status==1);
}
}
我们没有改变我们线条的核心逻辑,而是将逻辑重定向到当前线条的上下文中:
can = document.getElementById(id);
这样我们就可以提取任何直接提及年份或元素的提及,而不直接引用元素名称。这样我们可以添加或删除元素,我们只需要在 HTML 文件中添加另一个画布,添加新属性,并在创建函数中添加线条。这仍然很多,那么在继续前进到更有创意的领域之前,我们如何继续优化这段代码呢?
还有更多...
我们这个食谱的最终目标是帮助最小化用户需要进行的更改线条的步骤数量。目前,要添加更多线条,用户需要在三个地方进行更改。接下来的一些优化技巧将帮助我们减少添加/删除线条所需的步骤数量。
优化drawChart
函数
我们的drawChart
函数经历了一次改头换面,但是现在,当我们创建我们的线条时,我们仍然直接引用我们当前的元素:
changeLineView("i2011",barData.i2011.status);
changeLineView("i2010",barData.i2010.status);
changeLineView("i2009",barData.i2009.status);
相反,让我们利用barData
对象并使用该对象的数据键。这样我们完全可以避免直接引用我们的显式元素的需要,而是依赖于我们的数据源作为信息来源:
for(var id in barData){
changeLineView(id,barData[id].status);
}
完美!现在我们barData
对象中的任何更改都将定义在应用程序启动时最初呈现的元素。我们刚刚减少了用户需要进行的更改次数到两次。
进一步简化我们的代码
我们现在比刚开始时要好得多。最初,我们的代码中有三个地方直接引用了图表信息的硬编码值。在最后一次更新中,我们将其减少到了两个(一次在 HTML 文件中,一次在数据源中)。
现在是时候删除另一个硬编码的实例了。让我们删除我们额外的画布,并动态创建它们。
所以让我们从 HTML 文件中删除我们的图表画布元素,并为我们的<div>
标签设置一个 ID(删除突出显示的代码片段):
<div id="chartContainer" class="graphicLayers" >
<canvas id="base" class="canvasLayer" width="550" height="400"> </canvas>
<canvas id="i2011" class="canvasLayer" width="550" height="400"> </canvas>
<canvas id="i2010" class="canvasLayer" width="550" height="400"> </canvas>
<canvas id="i2009" class="canvasLayer" width="550" height="400"> </canvas>
</div>
顺便说一句,我们为包含图层的<div>
添加了一个 ID,这样我们就可以在 JavaScript 中轻松访问它并进行更改。
现在我们的图层没有任何画布,我们希望在第一次绘制图表时动态创建它们(这发生在drawChart
函数中,我们刚刚在优化drawChart
函数部分中创建的新for
循环中):
var chartContainer = document.getElementById("chartContainer");
for(var id in barData){
can = document.createElement("canvas");
can.id=id;
can.width=wid;
can.height=hei;
can.setAttribute("class","canvasLayer");
chartContainer.appendChild(can);
changeLineView(id,barData[id].status);
}
}
刷新您的 HTML 文件,您会发现我们的画布元素看起来和以前一样。我们还有最后一件事要解决,那就是我们的控制器,它们目前在 HTML 文件中是硬编码的。
动态创建单选按钮
另一个可以是动态的部分是我们创建单选按钮。所以让我们从 HTML 文件中删除单选按钮,并为我们的包装器添加一个 ID(删除突出显示的代码片段):
<div id="chartContainer" class="controllers">
2009 : <input type="radio" name="i2009" value="-1" /> off
<input type="radio" name="i2009" value="0" /> line
<input type="radio" name="i2009" value="1" select="1" /> full ||
2010 : <input type="radio" name="i2010" value="-1" /> off
<input type="radio" name="i2010" value="0" /> line
<input type="radio" name="i2010" value="1" select="1" /> full ||
2011 : <input type="radio" name="i2011" value="-1" /> off
<input type="radio" name="i2011" value="0" /> line
<input type="radio" name="i2011" value="1" select="1" /> full
</div>
回到我们的 HTML 文件,让我们创建一个创建新单选按钮的函数。我们将其称为appendRadioButton
函数:
function appendRadioButton(container, id,value,text){
var radioButton = document.createElement("input");
radioButton.setAttribute("type", "radio");
radioButton.setAttribute("value", value);
radioButton.setAttribute("name", id);
container.appendChild(radioButton);
container.innerHTML += text;
}
最后但同样重要的是在我们开始与它交互之前绘制我们的新按钮:
function init(){
drawChart();
var radContainer = document.getElementById("controllers");
var hasLooped= false;
for(var id in barData){
radContainer.innerHTML += (hasLooped ? " || ":"") + barData[id].label +": " ;
appendRadioButton(radContainer,id,-1," off ");
appendRadioButton(radContainer,id,0," line ");
appendRadioButton(radContainer,id,1," full ");
hasLooped = true;
}
var radios ;
for(id in barData){
radios = document.getElementsByName(id);
for (var i=0; i<radios.length; i++){
radios[i].onchange = onChangedRadio;
if(radios[i].value == barData[id].status ){
radios[i].checked = true;
}
}
}
}
请注意,我们没有将两个for
循环整合在一起。尽管看起来可能是一样的,但分离是必要的。JavaScript 需要一些时间,几纳秒,才能将元素实际呈现到屏幕上,因此通过分离我们的循环,我们给浏览器一个机会来追赶。创建元素和操作元素之间的分离主要是为了让 JavaScript 有机会在与创建的元素交互之前呈现 HTML 文件。
干得好!我们刚刚完成了更新我们的内容,使其完全动态化。现在一切都通过一个位置控制,即数据源,我们准备开始在接下来的食谱中探索分层画布逻辑。
转向面向对象的视角
我们的应用程序一直在不断发展。现在是时候通过将我们的图表更改为更符合面向对象编程的方式来停止了。在这个食谱中,我们将进一步清理我们的代码,并将其中一些转换为对象。我们将继续从上一个食谱堆叠图形层中离开的地方继续。
准备工作
第一步是获取我们的最新源文件:06.01.layers.optimized.html
和06.01.layers.optimized.js
。我们将重命名它们并添加我们的动画逻辑。除了在我们的 HTML 文件中更改引用之外,我们不会在 HTML 文件中做任何其他更改,而是将注意力集中在 JavaScript 文件中。
在 JavaScript 中创建对象的最简单方法之一是使用函数。我们可以创建一个函数,并在函数名称中引用this
,通过这样做,我们可以将函数视为对象(有关更多详细信息,请参阅本食谱的工作原理...部分)。
如何做...
让我们立即开始将我们的代码转换为更符合面向对象编程的方式:
- 我们从 JavaScript 文件开始进行代码更改。创建
LineChart
构造方法:
function LineChart(chartInfo,barData){
this.chartInfo = chartInfo;
this.barData = barData;
this.HIDE_ELEMENT = -1;
this.LINE_ELEMENT = 0;
this.FILL_ELEMENT = 1;
this.CHART_PADDING = 20;
this.wid;
this.hei;
drawChart();
var radContainer = document.getElementById("controllers");
var hasLooped= false;
for(var id in barData){
radContainer.innerHTML += (hasLooped ? " || ":"") + barData[id].label +": " ;
appendRadioButton(radContainer,id,-1," off ");
appendRadioButton(radContainer,id,0," line ");
appendRadioButton(radContainer,id,1," full ");
hasLooped = true;
}
var radios ;
for(id in barData){
radios = document.getElementsByName(id);
for (var i=0; i<radios.length; i++){
radios[i].onchange = onChangedRadio;
if(radios[i].value == barData[id].status ){
radios[i].checked = true;
}
}
}
}
- 让我们更新所有函数,使其成为
LineChart
函数(我们的伪类)的原型:
LineChart.prototype.drawChart =function(){...}
LineChart.prototype.appendRadioButton = function(container, id,value,text){...}
LineChart.prototype.onChangedRadio = function (e){...}
LineChart.prototype.changeLineView = function(id,value){...}
LineChart.prototype.fillChart = function (context, chartInfo){...}
LineChart.prototype.addLine = function(context,data,style,isFill){ ...}
LineChart.prototype.formatData = function(data , labelCopy , style){...}
- 现在让我们来看看真正困难的部分。我们需要用
this
引用所有函数和对象变量。有关更改的完整列表,请查看源文件(因为我们不想为此占用太多页面)。这里是一个小样本:
LineChart.prototype.drawChart =function(){
var can = document.getElementById("base");
this.wid = can.width;
this.hei = can.height;
var context = can.getContext("2d");
context.lineWidth = 1;
context.fillStyle = "#eeeeee";
context.strokeStyle = "#999999";
context.fillRect(0,0,this.wid,this.hei);
context.font = "10pt Verdana, sans-serif";
context.fillStyle = "#999999";
context.moveTo(this.CHART_PADDING,this.CHART_PADDING);
context.rect(this.CHART_PADDING,this.CHART_PADDING,this.wid-this.CHART_PADDING*2,this.hei-this.CHART_PADDING*2);
context.stroke();
context.strokeStyle = "#cccccc";
this.fillChart(context,this.chartInfo);
var chartContainer = document.getElementById("chartContainer");
for(var id in this.barData){
can = document.createElement("canvas");
can.id=id;
can.width=this.wid;
can.height=this.hei;
can.setAttribute("class","canvasLayer");
chartContainer.appendChild(can);
this.changeLineView(id,this.barData[id].status);
}
}
//continue and update all methods of our new object
- 到目前为止,为了处理单选按钮,我们只创建了一个回调函数,该函数设置为所有单选按钮。当用户点击我们的单选按钮时,将触发事件。一个问题将出现,因为事件内部的作用域将会中断,因为
this
将是其他内容的this
引用,而不是我们的主对象。单选按钮有自己的作用域(自己的this
引用)。我们想要强制进行作用域更改;为此,我们将创建一个辅助函数:
LineChart.prototype.bind = function(scope, fun){
return function () {
fun.apply(scope, arguments);
};
}
- 我们现在将重写在
LineChart
构造函数中触发事件的行:
for (var i=0; i<radios.length; i++){
radios[i].onchange = this.bind(this, this.onChangedRadio);
if(radios[i].value == barData[id].status ){
radios[i].checked = true;
}
}
- 我们现在将重写我们的
init
函数。我们将在其中创建我们的数据点:
window.onload = init;
function init(){
var chartInfo= { y:{min:0, max:300, steps:5,label:"users"},
x:{min:1, max:12, steps:11,label:"months"}
};
var barData = {
i2011:{
status: FILL_ELEMENT,
style: "#B1DDF3",
label: "2011",
data:[38,65,85,111,131,160,187,180,205,146,64,212]
},
i2010:{
status: FILL_ELEMENT,
style: "#FFDE89",
label: "2010",
data:[212,146,205,180,187,131,291,42,98,61,74,69]
},
i2009:{
status: FILL_ELEMENT,
style: "#E3675C",
label: "2009",
data:[17,46,75,60,97,131,71,52,38,21,84,39]
}
};
chart = new LineChart(chartInfo,barData);
}
- 删除所有全局变量。
令人惊讶的是,你刚刚将所有逻辑移到了一个对象中。在我们的应用程序中没有任何全局变量,这样可以更容易地同时拥有多个图表。
工作原理...
我们将我们的更改保持在最小阶段。JavaScript 是一种面向对象的编程语言,因此我们可以通过将所有函数包装到一个新类中来利用它。我们首先创建一个构造函数。这个函数将被用作我们的对象类型/名称:
function MyFirstObject(){
//constructor code
}
要创建对象变量,我们将使用this
引用构造函数变量。this
运算符是一个动态名称,始终指的是当前作用域。在对象内部的当前作用域是对象本身;在我们的情况下,MyFirstObject
函数将如下所示:
function MyFirstObject(){
this.a = "value";
}
你仍然可以在函数内部使用常规变量定义来创建变量,但是,在那里,作用域不会是对象作用域,而是仅在该函数内部。因此,每当你想创建在整个对象中共享的变量时,你必须创建它们,并使用前导this
引用来引用它们。
下一步是将所有函数重命名为我们创建的新类(函数)的原型。这样,我们的函数将属于我们正在创建的新对象。我们希望过去的全局变量的转变成为当前对象的对象变量。每当我们想引用对象变量(属性)时,我们需要通过使用this
指令明确地让 JavaScript 知道我们的对象。例如,如果我们想引用sampleVar
变量,我们可以这样做:
this.sampleVar;
我们只遇到了一个问题,那就是当我们在代码中引入其他对象时。指令this
需要知道其位置的范围,以知道我们正在引用的是哪个对象。在使用事件的情况下,我们对this
指向我们的对象的期望将不成立。实际上,在事件侦听器中处理this
时,this
指令总是指向被侦听的元素,也就是被操作的元素。因此,向单选按钮添加事件将导致我们的范围被破坏。为了解决这个问题,我们创建一个函数,将我们的范围绑定到侦听器上。bind
方法将我们的函数绑定到当前范围。尽管默认情况下,侦听器的范围将是它正在侦听的对象,但我们强制范围保持在我们的对象上,使我们的代码更好地为我们工作。
这留下了我们的最后一个任务。我们需要创建我们对象的一个新实例。通过创建一个新实例,我们将激活我们迄今为止所做的所有工作。创建新对象的步骤与创建其他基本对象的步骤相同,只是这一次我们使用我们的构造函数名称:
new LineChart(chartInfo,barData);
我们对象的真正测试将是我们是否能创建多个图表实例。现在我们还不能,所以我们需要对我们的逻辑做一些更改才能使其工作。
还有更多...
尽管现在我们有一个可用的 OOP 对象,但它并没有真正优化,可以进行一些改进。由于我们在一个范围内,我们可以重新审视和重连可以发送的内容以及可以依赖内部变量的内容。我们将在本章的这一部分探讨下一个任务。
将我们的基本画布元素移到我们的构造函数中
让我们从drawChart
函数开始移动。以下逻辑将获取基本画布并在我们的新构造函数中创建一个全局变量:
var can = document.getElementById("base");
this.wid = can.width;
this.hei = can.height;
this.baseCanvas = can.getContext("2d");
接下来将替换drawChart
方法中的相关行,引用我们新创建的baseCanvas
对象:
LineChart.prototype.drawChart =function(){
var context = this.baseCanvas;
...
this.fillChart();
注意,我们从fillChart
方法中删除了函数参数,因为现在我们可以在方法内部传递它们:
LineChart.prototype.fillChart = function (){
var context = this.baseCanvas;
var chartInfo = this.chartInfo;
我强烈建议您继续以同样的方式优化其余的函数,但是对于我们的示例,让我们继续下一个主题。
动态创建所有 HTML 组件
我们为什么要动态创建我们的控制器和基本画布?因为我们提前创建了一些类,所以我们在每个 HTML 页面中只能有一个对象。如果我们动态创建了控制器或传递了类信息,我们就可以在我们的应用程序中启用创建多个控制器。由于我们正在动态创建许多元素,继续这样做似乎是合乎逻辑的。让我们首先动态创建剩下的两个元素。
让我们从 HTML 页面中删除内部画布细节(删除突出显示的代码片段):
<div id="chartContainer" class="graphicLayers" >
<canvas id="base" class="canvasLayer" width="550" height="400"> </canvas>
</div>
<div id="controllers" class="controllers">
</div>
我们将开始将控制器类插入到我们的全局<div>
标记中,该标记将用于我们的画布。我们需要更新控制器的 CSS 信息:
.controllers {
position: absolute;
left:0;
top:400px;
}
好的。我们现在准备对我们的构造函数进行一些代码更新。应该实现的更新代码片段已经突出显示:
function LineChart(chartInfo,barData,divID){
this.chartInfo = chartInfo;
this.barData = barData;
this.HIDE_ELEMENT = -1;
this.LINE_ELEMENT = 0;
this.FILL_ELEMENT = 1;
this.CHART_PADDING = 20;
this.BASE_ID = divID;
var chartContainer = document.getElementById(divID);
var can = document.createElement("canvas");
can.width=chartInfo.width;
can.height=chartInfo.height;
can.setAttribute("class","canvasLayer");
chartContainer.appendChild(can);
this.wid = can.width;
this.hei = can.height;
this.baseCanvas = can.getContext("2d");
this.drawChart();
var div = document.createElement("div");
div.setAttribute("class","controllers");
chartContainer.appendChild(div);
var radContainer = div;
var hasLooped= false;
for(var id in barData){
radContainer.innerHTML += (hasLooped ? " || ":"") + barData[id].label +": " ;
this.appendRadioButton(radContainer,id,-1," off ");
this.appendRadioButton(radContainer,id,0," line ");
this.appendRadioButton(radContainer,id,1," full ");
hasLooped = true;
}
var radios ;
for(id in barData){
radios = document.getElementsByName(id);
for (var i=0; i<radios.length; i++){
radios[i].onchange = this.bind(this, this.onChangedRadio);
if(radios[i].value == barData[id].status ){
radios[i].checked = true;
}
}
}
}
我们希望通过将<div>
标签 ID 发送到LineChart
对象来开始:
new LineChart(chartInfo,barData,"chartContainer");
如果您刷新屏幕,所有这些辛苦的工作应该是看不见的。如果一切仍然像我们开始做出改变之前一样工作,那么干得好,您刚刚完成了将图表转换为智能和动态的过程。
移除松散的部分
尽管我们提取了所有外部画布和控制器,并且一切都在运行,但我们仍然是以一种可能会破坏它们的方式引用内部画布元素和单选按钮。如果我们尝试在它们旁边创建一个镜像图表来解决这个问题,我们需要查看所有我们的新元素,并在它们的名称中添加一个唯一的键(我们可以使用div id
元素作为该键,因为在任何 HTML 应用程序中只能有一个具有相同 ID 的<div>
标签)。为了节省一些页面,我只会在这里展示基本逻辑,但请获取最新的代码包以查找所有更新。
LineChart.prototype.extractID = function(str){
return str.split(this.BASE_ID + "_")[1];
}
LineChart.prototype.wrapID = function(str){
return this.BASE_ID + "_"+str;
}
我创建了两个辅助函数,它们的作用很简单:通过将主<div>
标签 ID 添加到它们的名称中来重命名<div>
标签/类/单选按钮。这样我们就不会有重复的元素。剩下的就是定位我们创建元素的所有区域(我们在drawChart
函数中创建画布,在构造函数中创建单选按钮,但我们在一些函数中与它们交互)。搜索调用this.extractID
或this.wrapID
方法的更改,并理解为什么它们被调用。
通过创建两个图表来测试我们的工作
为了让生活变得更加困难,我们将使用相同的数据源两次创建完全相同的图表(因为这是一个很好的边缘案例,所以如果这样可以工作,任何图表都可以工作)。更新 HTML 文件并添加两个<div>
标签,并更新 CSS:
<!DOCTYPE html>
<html>
<head>
<title>Line Chart</title>
<meta charset="utf-8" />
<style>
#chartContainer {
position: relative;
left:100px
}
#chartContainer2{
position: relative;
left:700px
}
.controllers {
position: absolute;
left:0;
top:400px;
}
.canvasLayer{
position: absolute;
left: 0;
top: 0;
}
</style>
<script src="img/06.02.objects.optimized.js"></script>
</head>
<body style="background:#fafafa">
<h1>Users Changed between within a year</h1>
<div id="chartContainer" class="graphicLayers" >
</div>
<div id="chartContainer2" class="graphicLayers2" >
</div>
</body>
</html>
在我们的init
函数中让我们设置好两个图表:
new LineChart(chartInfo,barData,"chartContainer");
new LineChart(chartInfo,barData,"chartContainer2");
是的!我们有两个基于相同代码基础的交互式图表同时工作。干得好!不用担心,本章的其余部分会更容易一些。
独立层的动画
经过一些非常困难的配方之后,让我们做一些有趣且简单的事情;让我们为我们的图表添加一些动画,并添加一些淡入和延迟。
准备工作
我们应用程序的核心逻辑是在前两个配方堆叠图形层和转向面向对象编程中构建的。我们的状态非常良好,因此我们可以非常容易地扩展并创建内容并将其添加到我们的应用程序中。我们将对我们最新的 HTML 文件进行一些非常轻微的更新,主要是删除我们不需要的东西,然后就是 JavaScript 了。
从我们上一个示例(06.02.objects.optimized.html
和06.02.objects.optimized.js
)中获取最新的文件,然后让我们继续。
操作步骤...
在接下来的几个步骤中,我们的目标是删除不需要的代码,然后构建我们的分层动画。执行以下步骤:
- 删除不需要的 HTML、CSS 和
<div>
标签(删除高亮显示的代码片段):
<!DOCTYPE html>
<html>
<head>
<title>Line Chart</title>
<meta charset="utf-8" />
<style>
#chartContainer {
position: relative;
left:100px
}
#chartContainer2{
position: relative;
left:700px
}
.controllers {
position: absolute;
left:0;
top:400px;
}
.canvasLayer{
position: absolute;
left: 0;
top: 0;
}
</style>
<script src="img/06.02.objects.optimized.js"></script>
</head>
<body style="background:#fafafa">
<h1>Users Changed between within a year</h1>
<div id="chartContainer" class="graphicLayers" >
</div>
<div id="chartContainer2" class="graphicLayers2" >
</div>
</body>
</html>
- 创建新的
Animator
构造函数:
function Animator(refreshRate){
this.animQue = [];
this.refreshRate = refreshRate || 50; //if nothing set 20 FPS
this.interval = 0;
}
- 创建
add
方法:
Animator.prototype.add = function(obj,property, from,to,time,delay){
obj[property] = from;
this.animQue.push({obj:obj,
p:property,
crt:from,
to:to,
stepSize: (to-from)/(time*1000/this.refreshRate),
delay:delay*1000 || 0});
if(!this.interval){ //only start interval if not running already
this.interval = setInterval(this._animate,this.refreshRate,this);
}
}
- 创建内部的
_animate
方法:
Animator.prototype._animate = function(scope){
var obj;
var data;
for(var i=0; i<scope.animQue.length; i++){
data = scope.animQue[i];
if(data.delay>0){
data.delay-=scope.refreshRate;
}else{
obj = data.obj;
if(data.crt<data.to){
data.crt +=data.stepSize;
obj[data.p] = data.crt;
}else{
obj[data.p] = data.to;
scope.animQue.splice(i,1);
--i;
}
}
}
if( scope.animQue.length==0){
clearInterval(scope.interval);
scope.interval = 0; //so when next animation starts we can start over
}
}
- 在
LineChart
构造函数方法中创建一个新的Animate
对象并对关键组件进行动画处理:
function LineChart(chartInfo,barData,divID){
...
this.animator = new Animator(50);
var chartContainer =this.mainDiv;
var can = document.createElement("canvas");
can.width=chartInfo.width;
can.height=chartInfo.height;
can.setAttribute("class","canvasLayer");
chartContainer.appendChild(can);
this.animator.add(can.style,"opacity",0,1,.5,.2);
...
var div = document.createElement("div");
div.setAttribute("class","controllers");
chartContainer.appendChild(div);
this.animator.add(div.style,"opacity",0,1,.4,2.2);
...
- 在
drawChart
方法中为画布元素添加动画:
var delay = .75;
for(var id in this.barData){
can = document.createElement("canvas");
can.id=this.wrapID(id);
can.width=this.wid;
can.height=this.hei;
can.setAttribute("class","canvasLayer");
chartContainer.appendChild(can);
this.changeLineView(id,this.barData[id].status);
this.animator.add(can.style,"opacity",0,1,1,delay);
delay+=.5;
}
当您再次运行网页时,您会发现分离层的淡入效果。
它是如何工作的...
让我们从查看我们的Animator
构造函数开始。我们在构造函数中首先有一些变量:
function Animator(refreshRate){
this.animQue = [];
this.refreshRate = refreshRate || 50; //if nothing set 20 FPS
this.interval = 0;
}
这些变量是一切的关键。animQue
数组将存储我们发出的每个新动画请求。refreshRate
属性将控制我们的动画更新频率。更新得越频繁,我们的动画就会越流畅(刷新率的值越高,用户系统的压力就越小)。例如,如果我们想要有几个动画,一个在更平滑的设置中,另一个以较低的刷新率运行,我们可以设置两个不同的Animator
类,具有不同的刷新率。
我们的add
方法接收所有必要的信息来对属性进行动画处理:
Animator.prototype.add =
function(obj,property, from,to,time,delay){}
发送到动画的每个元素都会被转换为一个引用对象,该对象在动画运行时将被使用,并推送到我们的animQue
数组中:
this.animQue.push({obj:obj,
p:property,
crt:from,
to:to,
stepSize: (to-from)/(time*1000/this.refreshRate),
delay:delay*1000 || 0});
在队列中存储我们将需要动画元素的所有信息,从对象的当前状态到每个间隔应该进行多少变化。除此之外,我们还添加了一个延迟选项,使我们能够稍后开始动画。
我们只在这个函数中控制间隔的创建,所以在调用这个函数之前,将不会有间隔运行:
if(!this.interval){ //only start interval if not running already
this.interval = setInterval(this._animate,this.refreshRate,this);
}
现在是我们对象的内部逻辑的时间了。_animate
方法在有东西需要动画时被内部调用。换句话说,只要animQue
数组中有东西。它循环遍历所有animQue
数组元素,并对每个元素进行一些测试:
-
如果元素设置了延迟,它将通过
refreshRate
属性降低延迟值,使得在每次循环中延迟变小,直到变为零或更小。当这种情况发生时,下一步将触发。 -
现在延迟已经完成,
_animate
方法改变了状态。它开始为animQue
数组中的对象进行动画,直到data.crt
的值小于data.to
为止。 -
在测试从数组中移除元素之前,间隔将继续一次。这里的分步是帮助我们避免在核心逻辑中添加
if
语句,从而减少我们for
循环的复杂性。因为我们只需要测试一次,所以我们可以吸收一个额外的循环周期的成本。在这个额外的周期中,我们将确切的最终值强制给我们的对象,并将其从动画队列中移除。
这是唯一的奇怪逻辑,我们在这里强制将循环变量的值降低:
}else{
obj[data.p] = data.to;
scope.animQue.splice(i,1);
--i;
}
在这段代码中,我们正在移除我们的元素。一旦我们移除了元素,我们的i
的当前值将比应该的值大一个,因为我们的对象已经缩小了。为了解决这个问题,我们需要强制降低值,将其重置为新的当前索引。
最后,在每次更新结束时,我们检查一下我们的数组中是否有任何东西。如果数组为空,那么是时候移除间隔了。我们希望在不需要时避免间隔运行。下次触发add
方法时,它将重新启动间隔:
if( scope.animQue.length==0){
clearInterval(scope.interval);
scope.interval = 0; //reset interval variable
}
这就是我们逻辑的核心,现在是时候创建一个新的animator
对象,并开始发送我们想要动画的元素了。尝试一下,动画其他东西,并找到你喜欢的动画速度、延迟和属性之间的平衡。这个animator
类是所有动画库的基础,尽管我们的示例更简化,有更多的用户过载的可能性,比如多次发送相同的对象。
添加一个交互式图例
尽管我们之前创建了一个图例,但我们的图例注定是非交互式的,因为我们没有办法移动它。在这个示例中,我们将创建一个快速简单的图例,当用户在我们的图表上滚动时,它将更新其位置,并淡入淡出。
准备好
从我们之前的06.03.fade.html
和06.03.fade.js
中获取最新的文件,然后让我们开始吧。在这个例子中,我们将硬编码我们的值,但是提取动态元素的更模块化方法是使这个类可重用的好方法。
如何做...
这一次,我们将在LineChart
对象中创建一个方法,为我们创建图例。执行以下步骤:
- 创建
createLegend
方法:
LineChart.prototype.createLegend = function (){
var can = document.createElement("canvas");
can.width=70;
can.height=100;
can.setAttribute("class","canvasLayer");
chartContainer.appendChild(can);
this.legend = can;
this.updateLegend();
can.style.opacity = 0;
}
- 创建
updateLegend
方法:
LineChart.prototype.updateLegend = function(){
var wid = this.legend.width;
var hei = this.legend.height;
var context = this.legend.getContext("2d");
context.fillStyle = "rgba(255,255,255,.7)";
context.strokeStyle = "rgba(150,150,150,.7)";
context.fillRect(0,0,wid,hei);
context.strokeRect(5,5,wid-10,hei-10);
var nextY= 10;
var space = (hei-10 - this.chartInfo.bars * nextY) / this.chartInfo.bars;
for(var id in this.barData){
context.fillStyle = this.barData[id].style;
context.fillRect(10,nextY,10,10);
context.fillText(this.barData[id].label,25, nextY+9);
nextY+=10+space;
}
this.legend.style.left = this.wid +"px";
}
- 接下来,我们要创建一些方法,这些方法将被用作事件监听器。让我们添加一些监听器来控制我们的动画:
LineChart.prototype.onMouseMoveArea = function(e){
this.legend.style.top = (e.layerY) +"px";
}
LineChart.prototype.fadeInLegend = function(){
this.animator.add(this.legend.style,"opacity",this.legend.style.opacity,1,.5);
}
LineChart.prototype.fadeOutLegend = function(){
this.animator.add(this.legend.style,"opacity",this.legend.style.opacity,0,.5);
}
- 我们刚刚创建的方法现在准备好与回调方法链接,比如我们的
mainDiv
的onmouseover
或onmouseout
事件。我们将我们的范围绑定回我们的主对象,并在用户触发这些内置事件时触发我们之前创建的方法。让我们在构造函数中注册我们的监听器:
this.drawChart();
this.createLegend();
this.mainDiv.onmousemove = this.bind(this,this.onMouseMoveArea);
this.mainDiv.onmouseover = this.bind(this,this.fadeInLegend);
this.mainDiv.onmouseout = this.bind(this,this.fadeOutLegend);
- 在代码中添加一个变量,用于计算
drawChart
更新代码中图表中有多少个条形图:
this.chartInfo.bars = 0;
for(var id in this.barData){
this.chartInfo.bars++;
can = document.createElement("canvas");
can.id=this.wrapID(id);
can.width=this.wid;
can.height=this.hei;
can.setAttribute("class","canvasLayer");
chartContainer.appendChild(can);
this.changeLineView(id,this.barData[id].status);
this.animator.add(can.style,"opacity",0,1,1,delay);
delay+=.5;
}
干得好!当你刷新浏览器时,你会看到一个根据我们的鼠标移动而淡入/淡出和重新定位的传说。
它是如何工作的...
这一次的逻辑很简单,因为我们的应用程序已经很好地设置和优化了。我们的createLegend
方法为我们创建了一个新的画布区域,我们可以用它来制作我们的传说。我已经在其中添加了一些硬编码的值,但将它们提取到我们的chartInfo
变量中会是一个好主意。
唯一需要解释的是传说布局涉及的逻辑。我们需要知道我们的图表包含多少项,以避免再次循环遍历数据源或要求用户添加此信息。我们可以在第一次循环遍历用户生成的数据时计算这些信息,然后更新它以包含我们的总项数。
我们设置了我们的方法,这样我们就可以将动态数据直接放入我们的图表中。我留下了这个挑战给你去探索和为它设置基础。
还有更多...
还有一件事需要注意的是,如果你在这个例子中努力搜索并对我们的Animator
类进行压力测试,你会发现它并不是百分之百优化的。如果我们向Animator
类发送具有冲突指令的相同对象,它不会自动终止冲突。相反,它将运行直到完成(例如,它将同时淡出和淡入;它不会破坏我们的应用程序,但会产生不需要的结果)。为了解决这样的问题,我们需要修改我们的Animator
类来覆盖冲突的动画。
通过检查我们的动画队列是否已经有相同属性的相同对象在进行动画来解决动画冲突。我们将创建一个find
函数来帮助我们在animQue
属性中找到重复的索引:
Animator.prototype.find= function(obj,property){
for(var i=0; i<this.animQue.length; i++){
if(this.animQue[i].obj == obj && this.animQue[i].p == property) return i;
}
return -1;
}
该函数将扫描我们的animQue
数组并找到重复项。如果找到匹配项,将返回索引值。如果没有找到,将返回-1
。现在是时候更新我们的add
方法来使用这个新的find
方法了:
Animator.prototype.add = function(obj,property, from,to,time,delay){
obj[property] = from;
var index = this.find(obj,property);
if(index!=-1) this.animQue.splice(index,1);
this.animQue.push({obj:obj,
p:property,
crt:from,
to:to,
stepSize: (to-from)/(time*1000/this.refreshRate),
delay:delay*1000 || 0});
if(!this.interval){ //only start interval if not running already
this.interval = setInterval(this._animate,this.refreshRate,this);
}
}
太好了!问题解决了!虽然在这个例子中我们还没有解决动态传说,但我们将在下一个示例中创建一个新的传说方向,它将是同样动态的,也许更加动态,创建一个上下文感知的传说。
创建一个上下文感知的传说
我们的目标是创建一个根据用户鼠标悬停在应用程序上的位置而更新的传说。根据用户的鼠标位置,我们将更新我们的传说以反映用户鼠标下的信息。
准备工作
从上一个示例中获取最新的文件:06.04.legend.html
和06.04.legend.js
。
如何做...
我们不会在 HTML 文件中做任何改变,所以让我们直接进入 JavaScript 并构建我们的动态传说:
- 从
ChartLine
构造函数中删除 rollover/rollout 事件,因为我们希望保持我们的传说始终可见:
this.drawChart();
this.createLegend();
this.mainDiv.onmousemove = this.bind(this,this.onMouseMoveArea);
this.mainDiv.onmouseover = this.bind(this,this.fadeInLegend);
this.mainDiv.onmouseout = this.bind(this,this.fadeOutLegend);
- 更新
createLegend
方法:
LineChart.prototype.createLegend = function (){
var can = document.createElement("canvas");
can.width=90;
can.height=100;
can.setAttribute("class","canvasLayer");
chartContainer.appendChild(can);
this.legend = can;
this.updateLegend(null,-1);
can.style.left = this.wid +"px";
}
- 更新
updateLegend
方法:
LineChart.prototype.updateLegend = function(ren,currentXIndex){
var ren = ren || this.barData;
var wid = this.legend.width;
var hei = this.legend.height;
var context = this.legend.getContext("2d");
context.fillStyle = "rgba(255,255,255,.7)";
context.strokeStyle = "rgba(150,150,150,.7)";
context.fillRect(0,0,wid,hei);
context.strokeRect(5,5,wid-10,hei-10);
var nextY= 10;
var space = (hei-10 - this.chartInfo.bars * nextY) / this.chartInfo.bars;
var isXIndex = currentXIndex !=-1;
for(var id in ren){
context.fillStyle = this.barData[id].style;
context.fillRect(10,nextY,10,10);
context.fillText(this.barData[id].label + (isXIndex ? (":"+ this.barData[id].data[currentXIndex] ):""),25, nextY+9);
nextY+=10+space;
}
}
- 更改事件监听器
onMouseMoveArea
:
LineChart.prototype.onMouseMoveArea = function(e){
var pixelData;
var barCanvas;
var chartX = e.layerX-this.CHART_PADDING;
var chartWid = this.wid -this.CHART_PADDING*2;
var currentXIndex = -1;
if(chartX>=0 && chartX<= chartWid){
currentXIndex = Math.round(chartX/this.chartInfo.x.stepSize)
}
var renderList = {};
var count = 0;
for(var id in this.barData){
barCanvas = this.barData[id].canvas;
pixelData = barCanvas.getImageData(e.layerX, e.layerY, 1, 1).data
if( pixelData[3]){
count++;
renderList[id] = true; //there is content on this layer now
}
}
if(!count) renderList = this.barData;
this.updateLegend(renderList,currentXIndex);
}
- 我们需要将步长添加到我们的数据中。这个变量应该动态计算,因为如果我们可以计算出来,用户就不需要知道这个信息。因此,当我们在
fillChart
方法中计算步长时,我们将把这个计算添加到我们的chartInfo
对象中:
stepSize = rangeLength/steps;
this.chartInfo.x.stepSize = chartWidth/steps;
- 最后但同样重要的是,让我们直接将画布信息添加到我们的
barData
对象中,这样我们就可以轻松地与它交互(添加到drawChart
函数中):
for(var id in this.barData){
this.chartInfo.bars++;
can = document.createElement("canvas");
can.id=this.wrapID(id);
can.width=this.wid;
can.height=this.hei;
can.setAttribute("class","canvasLayer");
chartContainer.appendChild(can);
this.barData[id].canvas =can.getContext("2d");
this.changeLineView(id,this.barData[id].status);
this.animator.add(can.style,"opacity",0,1,1,delay);
delay+=.5;
}
我们应该已经准备好了。当你再次运行页面时,你的鼠标应该控制传说中基于你所在的确切坐标提供的信息。
它是如何工作的...
在上一节配方的最后两个步骤中,我们添加了一些辅助变量来帮助我们创建鼠标移动逻辑。这是一个有趣的部分,因为在这个示例中,我们首次向画布请求像素信息。我们将主要关注onMouseMoveArea
事件侦听器内的逻辑。
我们首先要确定画布区域的边界:
var chartX = e.layerX-this.CHART_PADDING;
var chartWid = this.wid -this.CHART_PADDING*2;
接下来将是对我们所在图表的当前区域进行快速计算:
var currentXIndex = -1;
if(chartX>=0 && chartX<= chartWid){
currentXIndex = Math.round(chartX/this.chartInfo.x.stepSize);
}
如果我们离开区域,我们的currentXIndex
变量将保持为-1
,而如果我们在区域内,我们将得到一个值,介于0
和数据源步数的最大可能值之间。我们将把这个值发送到我们新更新的updateLegend
方法中,该方法将把该索引信息的实际值从数据源附加到图例的渲染中。
接下来是一个for
循环,我们通过循环遍历我们的数据来测试我们的画布元素,看它们是否是不透明的:
var renderList = {};
var count = 0;
for(var id in this.barData){
barCanvas = this.barData[id].canvas;
pixelData = barCanvas.getImageData(e.layerX, e.layerY, 1, 1).data;
if( pixelData[3]){
count++;
renderList[id] = true; //there is content on this layer now
}
}
只有返回的数据确认鼠标指针下有内容,我们才会将该 ID 添加到renderList
对象中。renderList
对象将成为我们的中心;它将控制要发送到updateLegend
方法的图例数据字段。如果我们的鼠标位于绘制的元素上方,我们将展示与用户悬停相关的图例信息;如果没有,我们将不展示。
我们将更新调用updateLegend
方法的方式,但在将其发送到新参数之前,我们要确认我们确实发送了一些东西。如果我们的辅助(链接对象)为空,我们将发送原始对象。这样,如果鼠标指针下没有图表,一切都会渲染:
if(!count) renderList = this.barData;
this.updateLegend(renderList,currentXIndex);
是时候来看看updateLegend
方法内的变化了。第一件新事情就在第一行出现:
var ren = ren || this.barData;
这是一个很好的编码技巧,它使我们能够更新我们的ren
参数。它的工作方式非常简单;||
运算符将始终返回它看到的第一个真值。在我们的情况下,如果ren
参数为空,或为零,或为假,它将返回this.barData
中的值。逻辑很简单,如果ren
参数有内容,它将保持不变,而如果为空,则this.barData
属性将在ren
变量中设置。
var isXIndex = currentXIndex !=-1;
for(var id in ren){
context.fillStyle = this.barData[id].style;
context.fillRect(10,nextY,10,10);
context.fillText(this.barData[id].label + (isXIndex ? (":"+ this.barData[id].data[currentXIndex] ):""),25, nextY+9);
nextY+=10+space;
}
这确实是整个配方的魔力所在。我们不是通过this.barData
属性进行循环,而是通过包含我们要渲染的所有项目的键对象进行循环。在添加文本时,只需在添加文本时添加数据,如果有列出有效索引。
就是这样!我们刚刚添加了一个非常酷的动态图例,随着用户探索我们的图表而变化。
第七章:依赖于开源领域
在本章中,我们将涵盖:
-
创建一个仪表盘表(jqPlot)
-
创建一个动画 3D 图表(canvas3DGraph)
-
随着时间的推移绘制图表(flotJS)
-
使用 RaphaelJS 创建时钟
-
使用 InfoVis 制作一个日光图
介绍
开源数据可视化社区非常丰富和详细,有许多选项和一些真正令人惊叹的库。每个库都有其优点和缺点。有些是独立的代码,而其他依赖于其他平台,如 jQuery。有些非常庞大,有些非常小;没有一个选项适用于所有机会,但是有这么多的选择,最重要的是找出哪个库适合您。
在使用开源库时总会有一个权衡,主要是在文件大小和拖慢应用程序速度、加载时间等方面有太多功能的情况下。但是由于社区的丰富和创造力,很难避免在几分钟内创建出真正奇妙的图表,而不是几个小时。
在本章中,我们将探索使用一些这些选项。我们的目标不是根据项目的文档使用库,而是找到方法来覆盖内置库,以便更好地控制我们的应用程序,以防在应用程序的文档中找不到合适的解决方案。因此,本章的目标现在是双重的,即找到执行不是自然设置的事情的方法,并找到绕过问题的方法。
还有一件重要的事情要注意,所有这些开源库都有版权。建议您在继续之前检查项目的法律文件。
创建一个仪表盘表(jqPlot)
在这个配方中,我们将创建一个非常有趣的仪表盘表,并注入一些随机动画,使其看起来像是连接到实时数据源,比如汽车的速度:
准备工作
要开始,您需要使用 jQuery 和 jqPlot。这一次我们将从头开始。
要获取最新的脚本,请访问blog.everythingfla.com/?p=339
的创建者网站。
下载 jQuery 和 jqPlot,或者下载我们的源文件开始。
如何做...
让我们列出完成任务所需的步骤:
- 为我们的项目创建一个 HTML 页面:
<!DOCTYPE html>
<html>
<head>
<title>JQPlot Meter</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="./external/jqplot/jquery.jqplot.min.css">
<script src="img/jquery.min.js"></script>
<script src="img/jquery.jqplot.js"></script>
<script src="img/jqplot.meterGaugeRenderer.min.js"></script>
<script src="img/07.01.jqplot-meter.js"></script>
</head>
<body style="background:#fafafa">
<div id="meter" style="height:400px;width:400px; "></div>
</body>
</html>
-
创建
07.01.jqplot-meter.js
文件。 -
让我们添加一些辅助变量。我们将在渲染仪表时使用它们:
var meter;
var meterValue=0;
var startingSpeed = parseInt(Math.random()*60) + 30;
var isStarting = true;
var renderOptions= {
label: 'Miles Per Hour',
labelPosition: 'bottom',
labelHeightAdjust: -10,
intervalOuterRadius: 45,
ticks: [0, 40, 80, 120],
intervals:[25, 90, 120],
intervalColors:[ '#E7E658','#66cc66', '#cc6666']
};
- 现在是时候创建我们的仪表盘了。我们将使用 jQuery 来知道我们的文档何时被阅读,然后创建我们的图表。
$(document).ready(function(){
meter = $.jqplot('meter',[[meterValue]],{
seriesDefaults: {
renderer: $.jqplot.MeterGaugeRenderer,
rendererOptions:renderOptions
}
});
});
- 现在是时候为我们的图表添加动画了。让我们在
ready
监听器间隔的最后一行中添加(从现在开始直到配方结束):
$(document).ready(function(){
meter = $.jqplot('meter',[[meterValue]],{
seriesDefaults: {
renderer: $.jqplot.MeterGaugeRenderer,
rendererOptions:renderOptions
}
});
setInterval(updateMeter,30);
});
- 最后但同样重要的是,现在是创建
updateMeter
函数的时候了:
function updateMeter(){
meter.destroy();
if(isStarting && meterValue<startingSpeed){
++meterValue
}else{
meterValue += 1- Math.random()*2;
meterValue = Math.max(0,Math.min(meterValue,120)); //keep our value in range no mater what
}
meter = $.jqplot('meter',[[meterValue]],{
seriesDefaults: {
renderer: $.jqplot.MeterGaugeRenderer,
rendererOptions:renderOptions
}
});
}
做得好。刷新您的浏览器,您会发现一个动画速度计,看起来像是汽车在行驶(如果您只是想象)。
它是如何工作的...
这个任务真的很容易,因为我们不需要从头开始。为了使仪表运行,我们需要导入meterGaugeRenderer
库。我们通过将其添加到我们正在加载的 JavaScript 文件中来实现这一点。但让我们专注于我们的代码。我们 JavaScript 的第一步是准备一些全局变量;我们使用全局变量是因为我们希望在两个不同的函数中重复使用这些变量(当我们准备重置我们的数据时)。
var meter;
var meterValue=0;
var startingSpeed = parseInt(Math.random()*60) + 30;
var isStarting = true;
meter
变量将保存我们从开源库生成的仪表。meterValue
将是应用程序加载时的初始值。我们的startingSpeed
变量将是30
和90
之间的随机值。目标是每次从不同的地方开始,使其更有趣。应用程序一启动,我们希望我们的仪表快速动画到其新的基本速度(startingSpeed
变量)。最后,这与isStarting
变量相关联,因为我们希望有一个动画将我们带到基本速度。当我们到达那里时,我们希望切换到一个会导致动画改变的随机驾驶速度。现在我们已经设置了所有辅助变量,我们准备创建renderOptions
对象:
var renderOptions= {
label: 'Miles Per Hour',
labelPosition: 'bottom',
labelHeightAdjust: -10,
intervalOuterRadius: 45,
ticks: [0, 40, 80, 120],
intervals:[25, 90, 120],
intervalColors:[ '#E7E658','#66cc66', '#cc6666']
};
这个对象实际上是我们应用程序视觉效果的核心。(在 jqPlot 项目主页文档中还有其他选项可供您探索。)现在让我们回顾一些关键参数。
intervalOuterRadius
有一个有点棘手的名称,但实际上它是内半径。我们的仪表的实际大小由我们设置应用程序所在的div
的大小控制。intervalOuterRadius
控制速度计核心中内部形状的大小。
var renderOptions= {
label: 'Miles Per Hour',
labelPosition: 'bottom',
labelHeightAdjust: -10,
intervalOuterRadius: 45,
//ticks: [0, 40, 80, 120],
intervals:[10,25, 90, 120],
intervalColors:['#999999', '#E7E658','#66cc66', '#cc6666']
};
ticks
函数控制复制轮廓的位置。默认情况下,它会将我们的顶部范围除以 4(即 30、60、90 和 120)。intervals
和intervalColors
函数让仪表知道范围和内部、内部、饼颜色(与刻度分开)。
$(document).ready(function(){
meter = $.jqplot('meter',[[meterValue]],{
seriesDefaults: {
renderer: $.jqplot.MeterGaugeRenderer,
rendererOptions:renderOptions
}
});
setInterval(updateMeter,30);
});
要使用 jqPlot 库创建新图表,我们总是调用$.jqplot
函数。函数的第一个参数是div
层,这是我们的工作所在的地方。第二个参数是包含图表数据的二维数组(对于这个示例来说看起来有点奇怪,因为它期望一个二维数组,而我们的示例一次只包含一个数据条目,所以我们需要将它包装在两个数组中)。第三个参数定义了使用的渲染器和rendererOptions
(我们之前创建的)。
还有更多…
让我们再探索一些功能。
创建updateMeter
函数
updateMeter
函数每 30 毫秒调用一次。我们需要做的是每次调用时都清除我们的艺术品:
meter.destroy();
这将清除与我们的仪表相关的所有内容,以便我们可以重新创建它。
如果我们仍然处于应用程序的介绍部分,希望我们的速度达到目标速度,我们需要通过1
更新我们的meterValue
。
if(isStarting && meterValue<startingSpeed){
++meterValue;
}
如果我们已经通过了这个状态,想让我们的仪表随机上下波动,看起来像是驾驶速度的变化,我们将使用以下代码片段:
}else{
meterValue += 1- Math.random()*2;
meterValue = Math.max(0,Math.min(meterValue,120)); //keep our value in range no mater what
}
我们随机地向我们的仪表值添加一个介于-1
和1
之间的值。通过保持我们的值不低于0
且不高于120
,然后用我们的新的meterValue
值重新绘制我们的仪表,可以实现对我们结果的修正。
创建一个动画 3D 图表(canvas3DGraph)
这个配方真的很有趣。它基于 Dragan Bajcic 的源文件。它不是一个完整的图表库,但它是一个很棒的启发式图表,可以修改并用来创建您自己的 3D 数据可视化。
尽管我们附带示例中的源文件是从原始源文件(主要是canvas3DGraph.js
)修改的,但要获取本书中使用的开源项目的原始源,请访问我们的集中列表blog.everythingfla.com/?p=339
。
准备好了
如果您想关注我们的更新,请从提供的链接下载原始源文件,或者查看我们对 Dragan 的源文件所做的更改。
如何做到…
让我们马上开始,因为我们有很多工作要做:
- 创建 HTML 文件:
<!DOCTYPE html>
<html>
<head>
<title>canvas3DGraph.js</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="./external/dragan/canvas3DGraph.css">
<script src="img/canvas3DGraph.js"></script>
<script src="img/07.02.3d.js"></script>
</head>
<body style="background:#fafafa">
<div id="g-holder">
<div id="canvasDiv">
<canvas id="graph" width="600" height="600" ></canvas>
<div id="gInfo"></div>
</div>
</div>
</body>
</html>
- 创建 CSS 文件
canvas3DGraph.css
:
#g-holder {
height:620px;
position:relative;
}
#canvasDiv{
border:solid 1px #e1e1e1;
width:600px;
height:600px;
position:absolute;
top:0px; left:0px;
z-index:10;
}
#x-label{
position:absolute;
z-index:2;
top:340px;
left:580px;
}
#y-label{
position:absolute;
z-index:2;
top:10px;
left:220px;
}
#z-label{
position:absolute;
z-index:2;
top:540px;
left:10px;
}
#gInfo div.gText{
position:absolute;
z-index:-1;
font:normal 10px Arial;
}
-
现在是时候转到 JavaScript 文件了。
-
让我们添加一些辅助变量:
var gData = [];
var curIndex=0;
var trailCount = 5;
var g;
var trailingArray=[];
- 当文档准备就绪时,我们需要创建我们的图表:
window.onload=function(){
//Initialize Graph
g = new canvasGraph('graph');
g.barStyle = {cap:'rgba(255,255,255,1)',main:'rgba(0,0,0,0.7)', shadow:'rgba(0,0,0,1)',outline:'rgba(0,0,0,0.7)',formater:styleFormater};
for(i=0;i<100;i++){
gData[i] = {x:(Math.cos((i/10)) * 400 + 400), y:(1000-(i*9.2)), z:(i*10)};
}
plotBar();
setInterval(plotBar,40);
}
- 创建
plotBar
函数:
function plotBar(){
trailingArray.push(gData[curIndex]);
if(trailingArray.length>=5) trailingArray.shift();
g.drawGraph(trailingArray);//trailingArray);
curIndex++
if(curIndex>=gData.length) curIndex=0;
}
- 创建格式化函数
styleFormatter
:
function styleFormatter(styleColor,index,total){
var clrs = styleColor.split(",");
var alpha = parseFloat(clrs[3].split(")"));
alpha *= index/total+.1;
clrs[3] = alpha+")";
return clrs.join(",");
}
假设您正在使用我们修改过的开源 JavaScript 文件,现在您应该看到您的图表正在进行动画。(在这个食谱的更多内容部分,我们将深入研究这些更改以及我们为什么进行这些更改。)
它是如何工作的...
让我们首先以与 JavaScript 库交互的方式来查看我们的代码。之后我们将更深入地了解这个库的内部工作原理。
var gData = [];
var trailingArray=[];
var trailCount = 5;
var curIndex=0;
gData
数组将存储 3D 空间中所有可能的点。一个 3D 条形图将使用这些点创建(这些点是将作为对象放入这个数组中的 3D 点 x、y 和 z 值)。trailingArray
数组将存储视图中当前的条形图元素。trailCount
变量将定义同时可以看到多少条形图,我们的当前索引(curIndex
)将跟踪我们最新添加到图表中的元素。
当窗口加载时,我们创建我们的图表元素:
window.onload=function(){
//Initialise Graph
g = new canvasGraph('graph');
g.barStyle = {cap:'rgba(255,255,255,1)',main:'rgba(0,0,0,0.7)', shadow:'rgba(0,0,0,1)',outline:'rgba(0,0,0,0.7)',formatter:styleFormatter};
for(i=0;i<100;i++){
gData[i] = {x:(Math.cos((i/10)) * 400 + 400), y:(1000-(i*9.2)), z:(i*10)};
}
plotBar();
setInterval(plotBar,40);
}
在创建我们的图表之后,我们更新barStyle
属性以反映我们想要在条形图上使用的颜色。除此之外,我们还发送了一个格式化函数,因为我们希望单独处理每个条形图(在视觉上对它们进行不同处理)。然后我们创建我们的数据源——在我们的情况下是在我们的内部空间中旅行的Math.cos
。随意玩弄所有数据点;它会产生一些非常惊人的内容。在实际应用中,您可能希望使用实时或真实数据。为了确保我们的数据将从后到前堆叠,我们需要对数据进行排序,以便后面的 z 值首先呈现。在我们的情况下,不需要排序,因为我们的循环正在创建一个按顺序增长的 z 索引顺序,所以数组已经组织好了。
更多内容...
接下来我们调用plotBar
并且每 40 毫秒重复一次这个动作。
plotBar 的逻辑
让我们来审查一下plotBar
函数中的逻辑。这是我们应用程序的真正酷的部分,我们通过更新数据源来创建动画。我们首先将当前索引元素添加到trailingArray
数组中:
trailingArray.push(gData[curIndex]);
如果我们的数组长度为5
或更多,我们需要摆脱数组中的第一个元素:
if(trailingArray.length>=5) trailingArray.shift();
然后我们绘制我们的图表并将curIndex
的值增加一。如果我们的curIndex
大于数组元素,我们将其重置为0
。
g.drawGraph(trailingArray);//trailingArray);
curIndex++
if(curIndex>=gData.length) curIndex=0;
styleFormatter 的逻辑
每次绘制条形图时,我们的格式化函数都会被调用来计算要使用的颜色。它将获取条形图的索引和正在处理的图表中数据源的总长度。在我们的示例中,我们只是根据它们的位置改变条形图的alpha
值。(数字越大,我们就越接近最后输入的数据源。)通过这种方式,我们创建了我们的淡出效果。
function styleFormatter(styleColor,index,total){
var clrs = styleColor.split(",");
var alpha = parseFloat(clrs[3].split(")"));
alpha *= index/total+.1;
clrs[3] = alpha+")";
return clrs.join(",");
}
这个示例实际上还有更多。在不深入代码本身的情况下,我想概述一下这些更改。
为了控制我们的条形图的颜色,第三方包的第 66 行必须更改。因此,我引入了this.barStyle
并且替换了在创建条形图元素时硬编码值的所有引用(并设置了一些默认值):
this.barStyle = {cap:'rgba(255,255,255,1)',main:'rgba(189,189,243,0.7)', shadow:'rgba(77,77,180,0.7)',outline:'rgba(0,0,0,0.7)',formatter:null};
我为我们的条形图创建了一个样式生成器。这是为了帮助我们在外部格式化程序和内部样式之间重定向逻辑:
canvasGraph.prototype.getBarStyle= function(baseStyle,index,total){
return this.barStyle.formatter? this.barStyle.formatter(baseStyle,index,total):baseStyle;
}
我们创建了一个清除函数,以删除图表中的所有可视内容,这样我们每次调用它时就可以重新渲染数据:
canvasGraph.prototype.getBarStyle= function(baseStyle,index,total){
return this.barStyle.formatter? this.barStyle.formatter(baseStyle,index,total):baseStyle;
}
我们将绘制图表的逻辑移动到drawGraph
函数中,这样我可以同时删除图表,使得每次刷新所有数据更容易:
canvasGraph.prototype.drawGraph=function(gData){
//moved this to the drawGraph so i can clear each time its called.
this.clearCanvas();
// Draw XYZ AXIS
this.drawAxis();
this.drawInfo();
var len = gData.length;
for(i=0;i<len;i++){
this.drawBar(gData[i].x,gData[i].y,gData[i].z,i,len);
}
}
当前索引和长度信息现在通过drawBar
传递,直到它到达格式化函数。
最后但并非最不重要的是,我已经从构造函数中删除了绘制图表的部分,这样我们的图表将更有利于我们的动画想法。
随时间变化的图表(flotJS)
这个库的一个更令人印象深刻的特性是更新图表信息的简易性。当您第一次审查这个库及其样本时,就可以很容易地看出作者热爱数学和图表。我最喜欢的功能是图表可以根据输入动态更新其 x 范围。
我第二喜欢的功能是使用tickFormater
方法更新图表文本信息的简易性。
准备工作
要获取flotJS
库的最新版本,请访问我们的链接中心blog.everythingfla.com/?p=339
以获取图表开源库,或者下载我们书籍的源文件,在出版时包含最新版本02geek.com/books/html5-graphics-and-data-visualization-cookbook.htm
。
如何做...
让我们创建我们的 HTML 和 JavaScript 文件:
- 创建一个 HTML 文件:
<!DOCTYPE html>
<html>
<head>
<title>flot</title>
<meta charset="utf-8" />
<script src="img/jquery.min.js"></script>
<script src="img/jquery.flot.js"></script>
<script src="img/jquery.flot.fillbetween.js"></script>
<script src="img/07.03.flot.js"></script>
</head>
<body style="background:#fafafa">
<div id="placeholder" style="width:600px;height:300px;"></div>
</body>
</html>
- 创建一个新的 JavaScript 文件(
07.03.flot.js
),然后创建我们的数据源:
var males = {
//...
//please grab from source files its a long list of numbers
};Create helper variables:
var VIEW_LENGTH = 5;
var index=0;
var plot;
var formattingData = {
xaxis: { tickDecimals: 0, tickFormatter: function (v) { return v%12 + "/" + (2009+Math.floor(v/12)); } },
yaxis: { tickFormatter: function (v) { return v + " cm"; } }
};
- 创建一个
ready
事件并触发updateChart
:
$(document).ready(updateChart);
- 创建
updateChart
:
function updateChart() {
plot = $.plot($("#placeholder"), getData(), formattingData);
if(index+5<males['mean'].length){
setTimeout(updateChart,500);
}
}
- 创建
getData
:
function getData(){
var endIndex = index+5>=males.length?males.length-1:index+5;
console.log(index,endIndex);
var dataset = [
{ label: 'Male mean', data: males['mean'].slice(index,endIndex), lines: { show: true }, color: "rgb(50,50,255)" },
{ id: 'm15%', data: males['15%'].slice(index,endIndex), lines: { show: true, lineWidth: 0, fill: false }, color: "rgb(50,50,255)" },
{ id: 'm25%', data: males['25%'].slice(index,endIndex), lines: { show: true, lineWidth: 0, fill: 0.2 }, color: "rgb(50,50,255)", fillBetween: 'm15%' },
{ id: 'm50%', data: males['50%'].slice(index,endIndex), lines: { show: true, lineWidth: 0.5, fill: 0.4, shadowSize: 0 }, color: "rgb(50,50,255)", fillBetween: 'm25%' },
{ id: 'm75%', data: males['75%'].slice(index,endIndex), lines: { show: true, lineWidth: 0, fill: 0.4 }, color: "rgb(50,50,255)", fillBetween: 'm50%' },
{ id: 'm85%', data: males['85%'].slice(index,endIndex), lines: { show: true, lineWidth: 0, fill: 0.2 }, color: "rgb(50,50,255)", fillBetween: 'm75%' }
];
index++;
return dataset;
}
现在,如果您在浏览器中运行图表,您将一次看到 6 个月,每隔半秒,图表将通过将图表向前推一个月来更新,直到数据源的末尾。
工作原理...
flotJS
具有内置逻辑,在重新绘制时重置自身,这是我们的魔法的一部分。我们的数据源是从flotJS
的样本中借来的。我们实际上使用数据来表示一个虚构的情况。最初,这些数据代表了人们根据年龄的平均体重,按百分位数分解。但我们在这个例子中的重点不是展示数据,而是展示数据的可视化方式。因此,在我们的情况下,我们必须通过保持百分位数的原意来处理数据,但使用内部数据来展示多年来的平均值,而不是年龄,如下所示:
{'15%': [[yearID, value], [yearID, value]...
yearID
的值范围从2
到19
。我们希望将这些信息展示为如果我们从 2006 年开始选择我们的数据。每个yearId
将代表一个月(19 将是 2006 年之后 1.5 年的时间,而不是实际代表的年龄 19)。
所以让我们开始分解。现在我们知道我们将如何处理我们的数据集,我们想要限制我们在任何给定时间内可以看到的月数。因此,我们将添加两个辅助参数,一个用于跟踪我们当前的索引,另一个用于跟踪任何给定时间内可见元素的最大数量:
var VIEW_LENGTH = 5;
var index=0;
我们将为我们的 Flot 图创建一个全局变量,并创建一个格式化程序来帮助我们格式化将发送的数据。
var plot;
var formattingData = {
xaxis: { tickDecimals: 0, tickFormatter: function (v) { return v%12 + "/" + (2003+Math.floor(v/12)); } },
yaxis: { tickFormatter: function (v) { return v + " cm"; } }
};
请注意,tickFormater
使我们能够修改图表中刻度的外观方式。在 x 轴的情况下,目标是展示当前日期2/2012...
,在 y 轴上,我们希望在屏幕上打印出的数字后面添加cm
。
还有更多...
还有两件事情要讲——getData
函数和updateChart
函数。
获取数据函数
在flotJS
中,每个数据点都有一个 ID。在我们的情况下,我们想展示六种相关的内容类型。调整参数以查看它们如何改变视图的方式。在我们发送创建的数组之前,我们将索引 ID 更新一次,这样下次调用函数时它将发送下一个范围。
我们需要注意的另一件事是实际数据范围。由于我们没有发送完整的数据范围(而是最多5
个),我们需要验证索引后至少有五个项目,如果没有,我们将返回数组的最后一个元素,确保我们不会切割超过实际长度的部分:
var endIndex = index+5>=males.length?males.length-1:index+5;
更新图表函数
这部分可能是最简单的。相同的代码用于第一次渲染和所有后续渲染。如果数据集有效,我们创建一个超时,并再次调用此函数,直到动画完成。
使用 RaphaelJS 构建时钟
毫无疑问,这是本章中我最喜欢的示例。它基于 Raphael 网站上的两个示例的混合(我强烈建议你去探索)。尽管Raphael
不是一个绘图库,但它是一个非常强大的动画和绘图库,非常值得玩耍。
在这个示例中,我们将创建一个创意的时钟(我认为)。我计划玩这个库一两天,结果玩了整个周末,因为我玩得太开心了。我最终得到了一个数字变形时钟(基于 Raphael 在其网站上为字母变形创建的示例),并根据其网站上的极坐标时钟示例加入了一些弧线。让我们看看它的表现:
准备工作
就像本章中的其他部分一样,您需要 Raphael 的原始库。我已经将其添加到我们的项目中。所以只需下载文件,让我们开始吧。
要获取原始库,请访问本章的外部源文件中心blog.everythingfla.com/?p=339
。
如何做...
让我们构建我们的应用程序:
- 创建 HTML 文件:
<!DOCTYPE html>
<html>
<head>
<title>Raphael</title>
<meta charset="utf-8" />
<script src="img/jquery.min.js"></script>
<script src="img/raphael-min.js"></script>
<script src="img/07.04.raphael.js"></script>
<style>
body {
background: #333;
color: #fff;
font: 300 100.1% "Helvetica Neue", Helvetica, "Arial Unicode MS", Arial, sans-serif;
}
#holder {
height: 600px;
margin: -300px 0 0 -300px;
width: 600px;
left: 50%;
position: absolute;
top: 50%;
}
</style>
</head>
<body>
<div id="holder"></div>
</body>
</html>
- 现在是时候进入 JavaScript 文件
07.04.raphael.js
了。将路径参数复制到一个名为helveticaForClock
的对象中,以绘制数字0
到9
和:
符号。这实际上只是一个很长的数字列表,所以请从我们可下载的源文件中复制它们:
var helveticaForClock = {...};
- 我们将创建一个
onload
监听器,并将所有代码放入其中,以与 Raphael 示例中的代码风格相匹配:
window.onload = function () {
//the rest of the code will be put in here from step 3 and on
};
- 创建一个 600 x 600 大小的新
Raphael
对象:
var r = Raphael("holder", 600, 600);
- 现在我们需要使用一个辅助函数来找出弧线的路径。为此,我们将创建一个
arc
函数作为我们新创建的Raphael
对象的额外属性:
r.customAttributes.arc = function (per,isClock) {
var R = this.props.r,
baseX = this.props.x,
baseY = this.props.y;
var degree = 360 *per;
if(isClock) degree = 360-degree;
var a = (90 - degree) * Math.PI / 180,
x = baseX + R * Math.cos(a),
y = baseY - R * Math.sin(a),
path;
if (per==1) {
path = [["M", baseX, baseY - R], ["A", R, R, 0, 1, 1, baseX, baseY - R]];
} else {
path = [["M", baseX, baseY - R], ["A", R, R, 0, +(degree > 180), 1, x, y]];
}
var alpha=1;
if(per<.1 || per>.9)
alpha = 0;
else
alpha = 1;
return {path: path,stroke: 'rgba(255,255,255,'+(1-per)+')'};
};
- 创建我们时钟的小时绘制(00:00):
var transPath;
var aTrans = ['T400,100','T320,100','T195,100','T115,100'];
var base0 = helveticaForClock[0];
var aLast = [0,0,0,0];
var aDigits = [];
var digit;
for(i=0; i<aLast.length; i++){
digit = r.path("M0,0L0,0z").attr({fill: "#fff", stroke: "#fff", "fill-opacity": .3, "stroke-width": 1, "stroke-linecap": "round", translation: "100 100"});
transPath = Raphael.transformPath(helveticaForClock[aLast[i]], aTrans[i]);
digit.attr({path:transPath});
aDigits.push(digit);
}
var dDot = r.path("M0,0L0,0z").attr({fill: "#fff", stroke: "#fff", "fill-opacity": .3, "stroke-width": 1, "stroke-linecap": "round", translation: "100 100"});
transPath = Raphael.transformPath(helveticaForClock[':'], 'T280,90');
dDot.attr({path:transPath});
- 现在是时候为我们的
seconds
动画创建艺术品了:
var time;
var sec = r.path();
sec.props = {r:30,x:300,y:300}; //new mandatory params
var sec2 = r.path();
sec2.props = {r:60,x:300,y:300};
animateSeconds();
animateStrokeWidth(sec,10,60,1000*60);
- 创建
animateSeconds
递归函数:
function animateSeconds(){ //will run forever
time = new Date();
sec.attr({arc: [1]});
sec.animate({arc: [0]}, 1000, "=",animateSeconds);
sec2.attr({arc: [1,true]});
sec2.animate({arc: [0,true]}, 999, "=");
var newDigits = [time.getMinutes()%10,
parseInt(time.getMinutes()/10),
time.getHours()%10,
parseInt(time.getHours()/10) ];
var path;
var transPath;
for(var i=0; i<aLast.length; i++){
if(aLast[i]!=newDigits[i]){
path = aDigits[i];
aLast[i] = newDigits[i];
transPath = Raphael.transformPath(helveticaForClock[newDigits[i]], aTrans[i]);
path.animate({path:transPath}, 500);
}
}
}
- 创建
animateStrokeWidth
函数:
function animateStrokeWidth(that,startWidth,endWidth,time){
that.attr({'stroke-width':startWidth});
that.animate({'stroke-width':endWidth},time,function(){
animateStrokeWidth(that,startWidth,endWidth,time); //repeat forever
});
}
如果现在运行应用程序,您将看到我与 Raphael 库玩耍一天的成果。
它是如何工作的...
这个项目有很多元素。让我们开始关注弧线动画。请注意,我们在代码中使用的一个元素是当我们创建新的路径时(我们创建了两个)。我们添加了一些硬编码的参数,这些参数将在arc
方法中后来用于绘制弧线:
var sec = r.path();sec.props = {r:30,x:300,y:300}; //new mandatory params
var sec2 = r.path();sec2.props = {r:60,x:300,y:300};
我们这样做是为了避免每次将这三个属性发送到弧线中,并且使我们能够选择一个半径并坚持下去,而不是将其集成或硬编码到动画中。我们的arc
方法是基于 Raphael 示例中用于极坐标时钟的arc
方法,但我们对其进行了更改,使值可以是正数或负数(这样更容易来回动画)。
然后在animateSeconds
函数内部动画化时,使用arc
方法来绘制我们的弧线:
sec.attr({arc: [1]});
sec.animate({arc: [0]}, 1000, "=",animateSeconds);
sec2.attr({arc: [1,true]});
sec2.animate({arc: [0,true]}, 999, "=");
attr
方法将重置我们的arc
属性,以便我们可以重新对其进行动画处理。
顺便说一句,在animateStrokeWidth
中,我们正在将我们的描边宽度从最小值动画到最大值,持续 60 秒。
还有更多...
你真的以为我们完成了吗?我知道你没有。让我们看看其他一些关键步骤。
动画路径
这个库中更酷的事情之一是能够动画化路径。如果您曾经使用过 Adobe Flash 的形状 Tween,这看起来会非常熟悉——毫无疑问,这真的很酷。
这个想法非常简单。我们有一个具有许多路径点的对象。如果我们通过它们绘制线信息,它们将一起创建一个形状。我们借用了 Raphael 创建的一个列表,所以我们不需要从头开始,而且我们在其中改变的只是我们不希望我们的元素按照它们当前的路径绘制。我们需要做的就是使用内部的Raphael.transformPath
方法来转换它们的位置:
transPath = Raphael.transformPath(helveticaForClock[0], 'T400,100');
换句话说,我们正在抓取数字 0 的路径信息,然后将其转换,向右移动 400 像素,向下移动 100 像素。
在我们的源代码中,看起来我们正在循环执行该函数(这有点更复杂,但是压缩了):
for(i=0; i<aLast.length; i++){
digit = r.path("M0,0L0,0z").attr({fill: "#fff", stroke: "#fff", "fill-opacity": .3, "stroke-width": 1, "stroke-linecap": "round", translation: "100 100"});
transPath = Raphael.transformPath(helveticaForClock[aLast[i]], aTrans[i]);
digit.attr({path:transPath});
aDigits.push(digit);
}
基本上,我们正在循环遍历aLast
数组(我们要创建的数字列表),并为每个元素创建一个新的数字。然后,我们根据aTrans
数组中的转换信息确定数字的位置,然后通过添加一个新的路径到属性中将其绘制出来。最后但并非最不重要的是,我们将我们的数字保存到aDigits
数组中,以便在以后重新渲染元素时使用。
每次调用animateSeconds
函数(每秒一次),我们都会弄清楚数字是否发生了变化,如果发生了变化,我们就准备更新它的信息:
var newDigits = [time.getMinutes()%10,
parseInt(time.getMinutes()/10),
time.getHours()%10,
parseInt(time.getHours()/10)];
var path;
var transPath;
for(var i=0; i<aLast.length; i++){
if(aLast[i]!=newDigits[i]){
path = aDigits[i];
aLast[i] = newDigits[i];
transPath = Raphael.transformPath(helveticaForClock[newDigits[i]], aTrans[i]);
path.animate({path:transPath}, 500);
}
}
我们首先收集当前时间HH:MM
到一个数组中([H,H,M,M]
),然后查看我们的数字是否发生了变化。如果它们发生了变化,我们就从我们的helveticaForClock
函数中获取所需的新数据,并在我们的新路径信息中为我们的数字(路径)进行动画处理。
这涵盖了遵循此方法的最重要因素。
使用 InfoVis 制作一个日晕图
另一个非常酷的库是InfoVis
。如果我必须对这个库进行分类,我会说它是关于连接的。当您查看 Nicolas Garcia Belmonte 提供的丰富示例时,您会发现很多非常独特的关系数据类型。
这个库是通过 Sencha 的法定所有者免费分发的。(版权很容易遵循,但请查看您遇到的任何开源项目的说明。)
我们将从他的基本示例之一开始——源文件中的日晕示例。我做了一些改变,赋予它新的个性。日晕图的基本思想是展示节点之间的关系。树是有序的父子关系,而日晕图中的关系是双向的。一个节点可以与任何其他节点有关系,可以是双向或单向关系。一个完美适合这种情况的数据集是一个国家的总出口额的例子——从一个国家到所有其他从中获得出口的国家的线。
我们将保持相对简单,只有四个元素(Ben,Packt Publishing,02geek 和 InfoVis 的创建者 Nicolas)。我与他们每个人都有单向关系:作为02geek.com
的所有者,作为 Packt Publishing 的作者,以及作为 InfoVis 的用户。虽然这对我来说是真的,但并非所有其他人都与我有真正深入的关系。其中一些人与我有联系,比如 02geek 和 Packt Publishing,而对于这个例子来说,Nicolas 是一个我从未互动过的陌生人。这可以用日晕图来描述:
准备工作
和往常一样,您将需要源文件,您可以下载我们的示例文件,或者访问我们的聚合列表获取最新版本。
如何做...
让我们创造一些 HTML 和 JavaScript 的魔法:
- 创建一个 HTML 文件如下:
<!DOCTYPE html>
<html>
<head>
<title>Sunberst - InfoVis</title>
<meta charset="utf-8" />
<style>
#infovis {
position:relative;
width:600px;
height:600px;
margin:auto;
overflow:hidden;
}
</style>
<script src="img/jit-yc.js"></script>
<script src="img/07.05.jit.js"></script>
</head>
<body onload="init();">
<div id="infovis"></div>
</body>
</html>
- 其余的代码将在
07.05.jit.js
中。创建一个基本数据源如下:
var dataSource = [ {"id": "node0", "name": "","data": {"$type": "none" },"adjacencies": []}]; //starting with invisible root
- 让我们创建一个将为我们的图表系统创建所需节点的函数:
function createNode(id,name,wid,hei,clr){
var obj = {id:id,name:name,data:{"$angularWidth":wid,"$height":hei,"$color":clr},adjacencies:[]};
dataSource[0].adjacencies.push({"nodeTo": id,"data": {'$type': 'none'}});
dataSource.push(obj);
return obj;
}
- 为了连接这些点,我们需要创建一个函数,用于创建元素之间的关系:
function relate(obj){
for(var i=1; i<arguments.length; i++){
obj.adjacencies.push({'nodeTo':arguments[i]});
}
}
- 我们希望能够突出显示关系。为此,我们需要一种方法来重新排列数据并突出显示我们想要突出显示的元素:
function highlight(nodeid){
var selectedIndex = 0;
for(var i=1; i<dataSource.length; i++){
if(nodeid!= dataSource[i].id){
for(var item in dataSource[i].adjacencies)
delete dataSource[i].adjacencies[item].data;
}else{
selectedIndex = i;
for(var item in dataSource[i].adjacencies)
dataSource[i].adjacencies[item].data = {"$color": "#ddaacc","$lineWidth": 4 };
}
}
if(selectedIndex){ //move selected node to be first (so it will highlight everything)
var node = dataSource.splice(selectedIndex,1)[0];
dataSource.splice(1,0,node);
}
}
- 创建一个
init
函数:
function init(){
/* or the remainder of the steps
all code showcased will be inside the init function */
}
- 让我们开始建立数据源和关系:
function init(){
var node = createNode('geek','02geek',100,40,"#B1DDF3");
relate(node,'ben');
node = createNode('packt','PacktBub',100,40,"#FFDE89");
relate(node,'ben');
node = createNode('ben','Ben',100,40,"#E3675C");
relate(node,'geek','packt','nic');
node = createNode('nic','Nicolas',100,40,"#C2D985");
//no known relationships so far ;)
...
- 创建实际的旭日图并与 API 交互(我已将其简化到最基本的形式;在原始示例中,它更加详细):
var sb = new $jit.Sunburst({
injectInto: 'infovis', //id container
Node: {
overridable: true,
type: 'multipie'
},
Edge: {
overridable: true,
type: 'hyperline',
lineWidth: 1,
color: '#777'
},
//Add animations when hovering and clicking nodes
NodeStyles: {
enable: true,
type: 'Native',
stylesClick: {
'color': '#444444'
},
stylesHover: {
'color': '#777777'
},
duration: 700
},
Events: {
enable: true,
type: 'Native',
//List node connections onClick
onClick: function(node, eventInfo, e){
if (!node) return;
highlight(node.id);
sb.loadJSON(dataSource);
sb.refresh()
}
},
levelDistance: 120
});
- 最后但并非最不重要的是,我们希望通过提供其
dataSource
来渲染我们的图表,并首次刷新渲染:
sb.loadJSON(dataSource);
sb.refresh();
就是这样。如果运行应用程序,您将找到一个可点击和有趣的图表,并且只是展示了这个真正酷的数据网络库的功能。
它是如何工作的...
我将避免详细介绍实际 API,因为那相当直观,并且具有非常丰富的信息和示例库。因此,我将专注于我在此应用程序中创建的更改和增强功能。
在我们这样做之前,我们需要了解此图表的数据结构是如何工作的。让我们深入了解填充信息后数据源对象的外观:
{
"id": "node0",
"name": "",
"data": {
"$type": "none"
},
"adjacencies": [
{"nodeTo": "node1","data": {'$type': 'none'}},
{"nodeTo": "node2","data": {'$type': 'none'}},
{"nodeTo": "node3","data": {'$type': 'none'}},
{"nodeTo": "node4","data": {'$type': 'none'}}
]
},
{
"id": "node1",
"name": "node 1",
"data": {
"$angularWidth": 300,
"$color": "#B1DDF3",
"$height": 40
},
"adjacencies": [
{
"nodeTo": "node3",
"data": {
"$color": "#ddaacc",
"$lineWidth": 4
}
}
]
},
有一些重要因素需要注意。首先是有一个基本父级,它是所有无父节点的父级的父级。在我们的情况下,它是一个平面图表。真正令人兴奋的关系是在相同级别的节点之间。因此,主父级与所有接下来的节点都有关系。子元素,例如在这种情况下的node1
,可能具有关系。它们在一个名为adjacencies
的数组中列出,其中包含对象。唯一强制性的参数是nodeTo
属性。它让应用程序知道单向关系列表。还有一些可选的布局参数,我们将在需要突出显示一条线时才添加。因此,让我们看看如何使用一些函数动态创建这种类型的数据。
createNode
函数通过将脏步骤封装在一起,帮助我们保持代码清晰。我们添加的每个新元素都需要添加到数组中,并且需要更新我们的主父元素(始终位于新元素数组的位置0
):
function createNode(id,name,wid,hei,clr){
var obj = {id:id,name:name,data:{"$angularWidth":wid,"$height":hei,"$color":clr},adjacencies:[]};
dataSource[0].adjacencies.push({"nodeTo": id,"data": {'$type': 'none'}});
dataSource.push(obj);
return obj;
}
我们返回对象,因为我们希望继续并建立与该对象的关系。一旦我们创建一个新对象(在我们的init
函数中),我们就调用relate
函数,并将所有与其相关的关系发送给它。relate
函数的逻辑看起来比实际上更复杂。该函数使用 JavaScript 中的一个隐藏或经常被忽略的特性,该特性使开发人员能够使用arguments
数组将开放数量的参数发送到函数中,该数组在每个函数中都会自动创建。我们可以将这些参数作为名为arguments
的数组获取:
function relate(obj){
for(var i=1; i<arguments.length; i++){
obj.adjacencies.push({'nodeTo':arguments[i]});
}
}
arguments
数组内置在每个函数中,并存储已发送到函数中的所有实际信息。由于第一个参数是我们的对象,我们需要跳过第一个参数,然后将新关系添加到adjacencies
数组中。
我们最后一个与数据相关的函数是我们的highlight
函数。highlight
函数期望一个参数nodeID
(我们在createNode
中创建)。highlight
函数的目标是遍历所有数据元素,并取消突出显示限于所选元素及其关系的所有关系。
function highlight(nodeid){
var selectedIndex = 0;
for(var i=1; i<dataSource.length; i++){
if(nodeid!= dataSource[i].id){
for(var item in dataSource[i].adjacencies)
delete dataSource[i].adjacencies[item].data;
}else{
selectedIndex = i;
for(var item in dataSource[i].adjacencies)
dataSource[i].adjacencies[item].data = {"$color": "#ddaacc","$lineWidth": 4 };
}
}
}
如果我们没有highlight
,我们希望确认并删除节点的邻接数据对象的所有实例,而如果它被选中,我们需要通过设置它的颜色和更粗的线来添加相同的对象。
数据几乎都完成了。但是在运行应用程序时,如果我们就此结束,你会发现一个问题。问题出在图表系统的工作方式上。如果画了一条线,它将不会再次重绘。实际上,如果我们选择“Ben”,而ben
不是列表中的第一个元素,那么“Ben”与其他人的所有关系都将不可见。为了解决这个问题,我们希望将所选节点推到位置0
(主要父节点)之后的第一个元素,这样它将首先渲染所选的关系:
if(selectedIndex){
var node = dataSource.splice(selectedIndex,1)[0];
dataSource.splice(1,0,node);
}
还有更多...
还有一件事是,当用户点击一个元素时,我们需要能够刷新我们的内容。为了完成这个任务,我们需要在jit.Sunburst
的初始化参数对象中添加一个事件参数:
var sb = new $jit.Sunburst({
injectInto: 'infovis', //id container
...
Events: {
enable: true,
type: 'Native',
//List node connections onClick
onClick: function(node, eventInfo, e){
if (!node) return;
highlight(node.id);
sb.loadJSON(dataSource);
sb.refresh();
}
},
levelDistance: 120
});
在这个示例中需要注意的另一件事是levelDistance
属性,它控制着你与渲染元素的距离(使其变大或变小)。
副本在哪里?
还有一个问题。我们的图表中没有任何副本,让我们知道实际点击的是什么。我从原始示例中删除了它,因为我不喜欢文本的定位,也搞不清楚如何把它弄对,所以我想出了一个变通方法。你可以直接与画布交互,直接在画布上绘制。画布元素将始终以与我们项目相同的 ID 命名(在我们的情况下是infovis
后跟着-canvas
):
var can = document.getElementById("infovis-canvas");
var context = can.getContext("2d");
...
剩下的就留给你去探索了。逻辑的其余部分很容易理解,因为我已经简化了它。所以如果你也喜欢这个项目,请访问 InfoVis Toolkit 网站,并尝试更多他们的界面选项。
第八章:使用 Google 图表玩耍
在这一章中,我们将涵盖:
-
使用饼图开始
-
使用 ChartWrapper 创建图表
-
将数据源更改为 Google 电子表格
-
使用选项对象自定义图表属性
-
向图表添加仪表板
介绍
在这一章中,我们将逐个任务地探索 Google 可视化 API。我们将看一下创建图表并将其与图表 API 集成的步骤。
要使用 Google API,您必须遵守 Google 的使用条款和政策,可以在google-developers.appspot.com/readme/terms
找到。
使用饼图开始
在这个第一个示例中,我们将从 Google 图表开始,涵盖您在使用 Google 图表时需要了解的基本步骤,通过基于美国 CDC(LCWK)2008 年美国 15 个主要死因的死亡率的交互式数据集——死亡人数、总死亡人数的百分比以及按种族和性别分组的五年龄段内的死亡率。
准备工作
我们将从一个空的 HTML 文件和一个名为08.01.getting-started.html
和08.01.getting-started.js
的空 JavaScript 文件开始。
如何做...
让我们列出完成任务所需的步骤,从 HTML 文件开始:
- 让我们从创建一个
head
并将其链接到 Google 的jsapi
和我们的本地 JavaScript 文件开始:
<!DOCTYPE html>
<html>
<head>
<title>Google Charts Getting Started</title>
<meta charset="utf-8" />
<script src="img/jsapi"></script>
<script src="img/08.01.getting-started.js"></script>
</head>
- 然后创建一个空的
div
,带有id chart
:
<body style="background:#fafafa">
<div id="chart"></div>
</body>
</html>
现在,是时候进入08.01.getting-started.js
文件了。
- 让我们从 Google 的
jsapi
请求可视化 API:
google.load('visualization', '1.0', {'packages':['corechart']});
- 我们想要添加一个
callback
,当库准备就绪时将被触发:
google.setOnLoadCallback(init);
- 创建一个
init
函数如下:
function init(){
..
}
从现在开始,我们将分解在init
函数中添加的代码:
- 创建一个新的 Google 数据对象,并按以下代码片段中所示提供数据源:
data.addColumn('string', 'Type of Death');
data.addColumn('number', 'Deaths');
data.addRows([
['Diseases of heart', 616828],
['Malignant neoplasms', 565469],
['Chronic lower respiratory diseases', 141090],
['Cerebrovascular diseases', 134148],
['Accidents', 121902],
['Alzheimer\'s disease', 82435],
['Diabetes mellitus', 70553],
['Influenza and pneumonia', 56284],
['Suicide', 36035],
['Septicemia', 35927],
['Chronic liver disease and cirrhosis', 29963],
['Essential hypertension and hypertensive renal disease', 25742],
['Parkinson\'s disease', 20483],
['Homicide', 17826],
['All other causes', 469062]
]);
- 为图表创建一个
options
对象:
var options = {'title':'Deaths, for the 15 leading causes of death: United States, 2008',
'width':800,
'height':600};
- 使用以下代码片段创建并绘制图表:
var chart = new google.visualization.PieChart(document.getElementById('chart'));
chart.draw(data, options);
加载 HTML 文件。您将会发现一个工作的交互式图表,如下截图所示:
它是如何工作的...
让我们探索与 Google 图表一起工作的步骤。我们在使用 Google API 时首先要做的是将 Google 的 API 链接添加到我们的 HTML 文件中:
<script src="img/jsapi"></script>
现在,Google API 已加载到我们的应用程序中,我们可以请求我们希望使用的库。在我们的情况下,我们想要使用可视化 API 和corechart
包:
google.load('visualization', '1.0', {'packages':['corechart']});
请注意,我们正在请求版本 1.0;这可能会让人困惑,但实际上我们正在请求生产图表,1.0 始终是当前的生产版本。因此,如果您想要锁定一个版本,您需要发现它的代码版本并发送它,而不是 1.0 稳定版本。
在示例中,corechart
库定义了大多数基本图表。对于未包含的图表,您需要传入所需的额外包,例如表格图表:
google.load('visualization', '1.0', {'packages':['corechart','table']});
这涵盖了如何加载 API 的基础知识。但在我们完成加载过程之前,我们需要一种方式来进行回调,以便我们知道库何时可供我们操作:
google.setOnLoadCallback(init);
我们正在请求 Google API 让我们知道包何时加载,方式类似于我们向文档添加回调的方式。当 API 加载完成时,是时候让我们开始与图表 API 进行交互了。
在每个 Google 图表中,您可能想要探索三个组件:
-
创建数据源
-
向您的图表添加选项
-
创建图表
让我们探索所有这些选项。
所有 Google 图表都需要数据源。数据源格式是基于通过图表 API 创建的内部对象:
var data = new google.visualization.DataTable();
数据表是 2D 数组(或表)。它们像数据库一样有列和行。我们的下一步将是定义数据列:
data.addColumn('string', 'Type of Death');
data.addColumn('number', 'Deaths');
在我们的情况下,由于我们正在使用饼图,只需要两行——一行用于命名我们的元素,另一行用于为它们提供值。addColumn
方法只有一个强制参数来定义数据类型。数据类型可以是以下之一:
-
字符串
-
数字
-
布尔
-
日期
-
日期时间
-
timeofday
第二个参数是数据类型的可选描述,用于可视化,例如在我们的情况下是10 Deaths
。还有其他参数,但只要我们按照顺序提供元素,我们就不需要探索它们。
最后但并非最不重要的,我们将调用addRows
方法。我们可以调用addRows
方法并发送一个一维数组(再次按照我们设置addColumn
的数据顺序)。在我们的情况下,我们正在使用期望二维数组的addRows
方法:
data.addRows([
['Diseases of heart', 616828],
....
]);
这涵盖了我们的数据集。只要我们按照我们的数据顺序设置列并通过数组发送我们的信息,我们就不需要深入研究数据 API。
options
对象使我们能够创建和修改图表的元素。我们在应用程序中控制的元素是宽度、高度和标题。
创建数据源并为我们的数组设置选项后,现在是简单的部分。创建图表的第一步是选择图表类型并定义它将被创建的位置。然后我们用数据源和选项来渲染它:
var chart = new google.visualization.PieChart(document.getElementById('chart'));
chart.draw(data, options);
还有更多...
让我们探索一些谷歌图表的技巧和高级功能。使用选项Objectto 创建 3D 图表
,我们可以将我们的图表转换为 3D。我们可以非常快速简单地将一个新参数添加到选项对象中:
var options = {'title':'Deaths, for the 15 leading causes of death: United States, 2008',
'width':800,
'height':600,
"is3D": true};
结果将是一个在 3D 空间中倾斜的图表。
更改图表类型
更改图表类型并不复杂。只要图表类型共享相同数量的数据条目,更改通常只是从图表的实际构造对象中的一个单词。例如,我们可以通过更改调用可视化库中的方法来非常快速地切换图表类型:
var chart = new google.visualization.LineChart(document.getElementById('chart'));
chart.draw(data, options);
这将使用相同的数据,只是呈现为线图(LineChart
对象)。
使用 ChartWrapper 创建图表
使用谷歌图表创建图表有两种方法。一种是我们在使用饼图入门中所做的方式,另一种将在本教程中介绍。ChartWrapper 对象的目标是使您能够减少创建图表所需的代码量。
它的主要优点是代码更少,数据源的灵活性更大。它的缺点是对图形创建步骤的控制较少。
做好准备
从上一个教程(使用饼图入门)中获取 HTML 文件。我们只会修改外部 JavaScript 文件的文件路径,其余代码将保持不变。
如何做...
在更改 HTML 文件源路径为 JavaScript 文件之后,现在是时候进入 JavaScript 文件并重新开始了:
- 加载谷歌 API(您不需要再提及您想要加载的内容),并添加一个回调:
google.load('visualization', '1.0');
google.setOnLoadCallback(init);
- 创建
init
函数:
function init(){
...
}
- 使用数据源构建一个 2D 数组:
var dataTable = [
['Type of Death','Deaths'],
['Diseases of heart', 616828],
['Malignant neoplasms', 565469],
['Chronic lower respiratory diseases', 141090],
['Cerebrovascular diseases', 134148],
['Accidents ', 121902],
['Alzheimer\'s disease ', 82435],
['Diabetes mellitus', 70553],
['Influenza and pneumonia', 56284],
['Suicide', 36035],
['Septicemia', 35927],
['Chronic liver disease and cirrhosis', 29963],
['Essential hypertension and hypertensive renal disease', 25742],
['Parkinson\'s disease', 20483],
['Homicide', 17826],
['All other causes', 469062]
];
- 创建
options
对象:
var options = {'title':'Deaths, for the 15 leading causes of death: United States, 2008',
'width':800,
'height':600,
"is3D": true};
- 构建和渲染图表:
var chart = new google.visualization.ChartWrapper({
chartType:'PieChart',
dataTable:dataTable,
options:options,
containerId:'chart'
});
chart.draw();
您已经完成了创建这种图表类型。刷新您的屏幕,您将看到与上一个例子中相同的图表,只是使用了更少的代码。
它是如何工作的...
这个例子的好处是你不需要知道更多关于它是如何工作的。ChartWrapper
函数本身处理了你在上一个教程中需要处理的所有信息。话虽如此,并不意味着这种方式总是更好的方式——如果你需要更多对步骤的控制,上一个例子会更好地工作。
还有更多...
由于这个教程非常简单,让我们添加一个额外的指针。
在一行中更改图表
在 Google Chart API 的不同视图类型之间切换非常容易。你只需要切换类型。让我们把我们的图表改成BarChart
:
var chart = new google.visualization.ChartWrapper({
chartType:'BarChart',
dataTable:dataTable,
options:options,
containerId:'chart'
});
刷新你的窗口,你会发现一个条形图。
将数据源更改为 Google 电子表格
与 Google API 合作的一个强大功能是产品线之间的深层关系。在这个配方中,基于上一个配方,我们将创建一个 Google 电子表格,然后将其整合到我们的应用程序中。
准备工作
在你周围备有上一个配方的源文件的副本(使用 ChartWrapper 创建图表)。
操作步骤...
创建新的 Google 文档所涉及的步骤很简单,但需要能够整合我们的工作;因此我们将快速地运行一遍。
-
转到
drive.google.com/
(以前称为 Google Docs)并注册/登录。 -
创建一个新的电子表格。
-
向电子表格添加数据。
-
点击分享按钮并将视图设置为公开:
-
根据文档 ID 创建 API URL:
- 文档链接:
docs.google.com/spreadsheet/ccc?key=0Aldzs55s0XbDdFJfUTNVSVltTS1ZQWQ0bWNsX2xSbVE
- API 链接:
spreadsheets.google.com/tq?key=0Aldzs55s0XbDdFJfUTNVSVltTS1ZQWQ0bWNsX2xSbVE
- 现在,是时候进入我们的 JavaScript 文件,删除当前数据源,并用 URL feed 替换它:
google.load('visualization', '1.0');
google.setOnLoadCallback(init);
function init(){
var options = {'title':'Deaths, for the 15 leading causes of death: United States, 2008',
'width':800,
'height':600};
var chart = new google.visualization.ChartWrapper({
chartType:'BarChart',
dataSourceUrl:"https://spreadsheets.google.com/tq?key=0Aldzs55s0XbDdFJfUTNVSVltTS1ZQWQ0bWNsX2xSbVE",
options:options,
containerId:'chart'
});
chart.draw();
}
太棒了!看看我们需要多少代码才能创建一个丰富而完全交互的图表:
它是如何工作的...
这真的是令人惊讶的部分。你不需要理解它是如何工作的,你只需要创建你的图表,并使用前一节提供的步骤,你就可以将你自己的任何电子表格转换成 Google 电子表格。
在前面的步骤中,最重要的一步是第 4 步。注意通过 Google 文档(Google Drive)生成的 URL 与在代码中工作时需要访问的 URL 不同。这是因为第一个 URL 旨在呈现为可视页面,而第二个链接生成一个新的 Google 数据对象。不要忘记每个页面都有自己独特的 ID。
还有更多...
如果你有一点关于使用数据库的背景,你可以将简单的 SQL 查询发送到数据源,只获取你想要查看的项目。比如在我们的例子中,我们想以不同的顺序获取项目,排除 B 列,并根据 D 列(按年龄)进行排序:
SELECT A,E,D,C ORDER BY D
我们的Select
语句列出了我们想要选择的内容。ORDER BY
语句不言自明。让我们把它添加到我们的代码中:
var chart = new google.visualization.ChartWrapper({
chartType:'BarChart',
dataSourceUrl:"https://spreadsheets.google.com/tq?key=0Aldzs55s0XbDdFJfUTNVSVltTS1ZQWQ0bWNsX2xSbVE",
query: 'SELECT A,E,D,C ORDER BY D',
options:options,
containerId:'chart'
});
当你刷新你的代码时,B 列将消失,数据将根据 D 列进行组织。
最后但并非最不重要的,将这添加到你的代码中:
var chart = new google.visualization.ChartWrapper({
chartType:'BarChart',
dataSourceUrl:"https://spreadsheets.google.com/tq?key=0Aldzs55s0XbDdFJfUTNVSVltTS1ZQWQ0bWNsX2xSbVE",
query: 'SELECT A,E,D,C ORDER BY D',
refreshInterval: 1,
options:options,
containerId:'chart'
});
chart.draw();
现在回到公共图表并更改其中的数据。你会发现它会自动更新图表。
使用选项对象自定义图表属性
在这个配方中,我们将使用 Google Charts API 创建一个新的图表——蜡烛图,并将各种配置整合到其中。
准备工作
我们将通过创建一个全新的 JavaScript 和 HTML 文件开始一个干净的板。
操作步骤...
大多数步骤看起来几乎与本章中的过去的配方相同。我们的主要重点将放在我们的options
参数上:
- 创建一个 HTML 文件并将其链接到一个 JavaScript 文件(在我们的例子中是
08.04.candlestick.js
):
<!DOCTYPE html>
<html>
<head>
<title>Google Charts Getting Started</title>
<meta charset="utf-8" />
<script src="img/jsapi"></script>
<script src="img/08.04.candlestick.js"></script>
</head>
<body style="background:#fafafa">
<div id="chart"></div>
</body>
</html>
- 在
08.04.candlestick.js
文件中,添加 API 的load
和callback
函数:
google.load('visualization', '1', {packages: ['corechart']});
google.setOnLoadCallback(init);
function init(){
- 在
init
函数中(从现在开始到本配方结束,我们将一直保持在init
函数中),使用google.visualization.arrayToDataTable
方法创建一个新的DataTable
对象:
var data = google.visualization.arrayToDataTable([
['Mon', 10, 24, 18, 21],
['Tue', 31, 38, 55, 74],
['Wed', 50, 55, 20, 103],
['Thu', 77, 77, 77, 77],
['Fri', 68, 66, 22, 15]
], true);
- 为图表创建一个
options
对象(配置对象):
var options = {
legend:'none',
backgroundColor:{fill:'#eeeeee',strokeWidth:2},
bar:{groupWidth:17},
candlestick:{hollowIsRising:true,
fallingColor:{stroke:'red',fill:'#ffaaaa'},
risingColor: {stroke:'blue',fill:'#aaaaff'}
},
enableInteractivity:false
};
- 使用以下代码片段绘制图表:
var chart = new google.visualization.CandlestickChart(document.getElementById('chart'));
chart.draw(data, options);
}
加载 HTML 文件后,您将发现一个定制的蜡烛图表,如下截图所示:
它是如何工作的...
这是我们第一次使用google.visualization.arrayToDataTable
方法。该方法接受一个数组并返回一个数据表。当此方法的第二个参数设置为true
时,它将将数组中的第一行视为数据的一部分;否则,它将被视为标题数据。
有许多选项,有关完整列表,请参阅 Google Charts 文档。我们将专注于我们选择修改视图的项目。Google 图表使您能够发送带有参数的对象。每种图表类型都有不同的选项集。在我们的情况下,我们有许多选项,使我们能够控制图表外观的细节。大多数选项与样式相关:
backgroundColor:{fill:'#eeeeee',strokeWidth:2},
bar:{groupWidth:17},
candlestick:{hollowIsRising:true,
fallingColor:{stroke:'red',fill:'#ffaaaa'},
risingColor: {stroke:'blue',fill:'#aaaaff'}
},
一些选项直接与功能相关,例如禁用图例:
legend:'none',
或者禁用交互元素:
enableInteractivity:false
还有更多...
突出显示这个元素的主要目的不是因为它很难,而是因为它很容易,这是您会发现自己对图表进行更改的主要地方。需要注意的一点是,在使用 Google Charts 之前,确保您可以通过使用 Google Charts 来做您需要的事情,因为与其他图表系统相反,您不能进入它们的源文件并对其进行更改,就像我们在第七章的示例中所做的那样,依赖于开源领域。
向图表添加仪表板
在本章的最后一个示例中,我们将添加实时控制器,使用户可以更改数据的过滤,以查看更少或更多的信息。
准备就绪
我们将从头开始,所以不用担心。
如何操作...
以下是创建基本仪表板控制器所需的步骤:
- 创建一个 HTML 文件并将其链接到外部 JavaScript 文件(在我们的例子中,我们将使用文件
08.05.slider.js
):
<!DOCTYPE html>
<html>
<head>
<title>Google Charts DASHBOARD</title>
<meta charset="utf-8" />
<script src="img/jsapi"></script>
<script src="img/08.05.slider.js"></script>
</head>
<body style="background:#fafafa">
<div id="chart"></div>
<div id="dashboard"></div>
<div id="filter"></div>
</body>
</html>
- 现在,是时候进入
08.05.slider.js
并加载 Google Visualization API 了。这一次我们将加载控制器包:
google.load('visualization', '1', {packages: ['controls']});
- 现在,是时候添加一个回调了:
google.setOnLoadCallback(init);
function init(){
- 让我们创建我们的数据源。我们将以 2008 年 CDC 死亡率为基础:
var data = google.visualization.arrayToDataTable([
['Age (+- 2 Years)', 'Deaths'],
[2, 4730],
[7, 2502],
[12, 3149],
[17, 12407],
[22, 19791],
[27,20786],
[32,21489],
[37,29864],
[42,46506],
[47,77417],
[52, 109125],
[57,134708],
[62,161474],
[67,183450],
[72,218129],
[77,287370],
[82,366190],
[87,372552],
[92,251381],
[100,20892],
]);
- 然后创建一个新的仪表板:
var dashboard = new google.visualization.Dashboard(document.getElementById('dashboard'));
- 让我们创建一个滑块并为其提供连接到数据源所需的信息:
var slider = new google.visualization.ControlWrapper({
containerId: 'filter',
controlType: 'NumberRangeFilter',
options: {
filterColumnLabel: 'Age (+- 2 Years)'
}
});
- 创建一个图表:
var chart = new google.visualization.ChartWrapper({
chartType: 'ScatterChart',
containerId: 'chart',
options: {
legend: 'left',
title:'Deaths, for the 15 leading causes of death: United States, 2008',
width: 800,
height: 600
}
});
- 最后但并非最不重要的,是时候绑定和绘制我们的控制器了:
dashboard.bind(slider, chart).draw(data);
}
加载 HTML 文件,您将发现一个散点图,带有一个控制器,可以选择您想要深入了解的年龄范围。
它是如何工作的...
这可能是使用 Google 图表 API 中最顺畅的部分之一。因此,让我们分解并弄清楚创建图表控制器涉及的步骤。我们将展示一个控制器,但相同的逻辑流程适用于所有组件。
首先,在我们的 HTML 文件中,我们需要有一个与我们的仪表板关联的div
层和每个后续控制器的div
。要添加控制器,我们将它们分配给仪表板。我们首先创建一个仪表板:
var dashboard = new google.visualization.Dashboard(document.getElementById('dashboard'));
这个仪表板现在将成为我们连接所有控制器的中心(在我们的情况下,一个控制器)。然后,我们将创建下一个控制器;在我们的情况下,我们想使用一个滑块:
var slider = new google.visualization.ControlWrapper({
containerId: 'filter',
controlType: 'NumberRangeFilter',
options: {
filterColumnLabel: 'Age (+- 2 Years)'
}
});
请注意,我们正在添加一个控件类型以获取我们的范围滑块,并通过给它列 ID(第一行中的标签)来将其链接到列。
我们继续以与之前相同的方式创建图表。在这种情况下,我们选择了散点图。这里的顺序并不重要,但最重要的部分是连接我们的控制器和图表。我们通过使用dashboard.bind
方法来实现这一点:
dashboard.bind(slider, chart);
然后,当创建一个bind
函数时,我们将我们的元素绘制为我们的仪表板返回自身:
dashboard.bind(slider, chart).draw(data);
如果我们想的话,我们可以将其拆分为如下的单独行:
dashboard.bind(slider, chart);
dashboard.draw(data);
现在你知道如何使用仪表板了。这些步骤很关键,但现在你可以添加任何控制器。这个产品的其余文档是不言自明的。
第九章:使用 Google 地图
在本章中,我们将涵盖:
-
使用 Google Visualization API 创建地理图表
-
获取 Google API 密钥
-
构建 Google 地图
-
添加标记和事件
-
自定义控件和重叠地图
-
使用样式重新设计地图
介绍
本章将致力于探索 Google 地图上的一些功能,以便让我们准备好处理地图工作。单独的地图并不是数据可视化,但是在我们通过了解如何处理地图来建立基础之后,我们将能够通过整合数据和数据可视化来创建许多尖端、酷炫的项目。
在本章中,我们将探索在 Google 领域创建地图的主要方法。
使用 Google Visualization API 创建地理图表
在本章的第一个配方中,我们将开始使用基于矢量的世界地图。我们将用它来根据数据源突出显示国家。在我们的情况下,我们将使用维基百科的国家列表,根据故意谋杀率(最新数据)。
要查看原始数据,请访问en.wikipedia.org/wiki/List_of_countries_by_intentional_homicide_rate
。
我们的目标是拥有一张世界地图,根据每 10 万人中故意谋杀的数量而突出显示一系列颜色。根据维基百科 2012 年的最新数据,它听起来像是最不安全的地方是洪都拉斯——如果你不想被故意杀害的话——而在日本你应该感到非常安全。你的国家怎么样?我的国家还不错。我可能应该避开让我感觉自己生活在战区的当地新闻台。
准备工作
不需要做太多事情。我们将使用 Google Visualization API 来创建地理图表。
如何做...
我们将创建一个新的 HTML 和一个新的 JavaScript 文件,并将它们命名为08.01.geo-chart.html
和08.01.geo-chart.js
。按照以下步骤进行:
- 在 HTML 文件中添加以下代码:
<!DOCTYPE html>
<html>
<head>
<title>Geo Charts</title>
<meta charset="utf-8" />
<script src="img/jsapi"></script>
<script src="img/08.01.geo-chart.js"></script>
</head>
<body style="background:#fafafa">
<div id="chart"></div>
</body>
</html>
- 让我们转到
js
文件。这一次,我们将要求从 Google Visualization 包中请求geochart
功能。为此,我们将从以下代码开始:
google.load('visualization','1',{'packages': ['geochart']});
- 然后我们将添加一个回调,当包准备就绪时将触发
init
函数:
google.setOnLoadCallback(init);
function init(){
//...
}
- 现在是时候在
init
函数中添加逻辑了。在第一步中,我们将从维基百科格式化数据为另一种格式,以便适用于 Google Visualization API:
var data = google.visualization.arrayToDataTable([
['Country','Intentional Homicide Rate per 100,000'],
['Honduras',87],['El Salvador',71],['Saint Kitts and Nevis',68],
['Venezuela',67],['Belize',39],['Guatemala',39],['Jamaica',39],
['Bahamas',36],['Colombia',33],['South Africa', 32],
['Dominican Republic',31],['Trinidad and Tobago',28],['Brazil',26],
['Dominica', 22],['Saint Lucia',22],['Saint Vincent and the Grenadines',22],
['Panama',20],['Guyana',18],['Mexico',18],['Ecuador',16],
['Nicaragua',13],['Grenada',12],['Paraguay',12],['Russia',12],
['Barbados',11],['Costa Rica',10 ],['Bolivia',8.9],
['Estonia',7.5],['Moldova',7.4],['Haiti',6.9],
['Antigua and Barbuda',6.8],['Uruguay',6.1],['Thailand',5.3],
['Ukraine',5.2],['United States',4.7 ],['Georgia',4.1],['Latvia',4.1 ],
['India',3.2],['Taiwan',3.0 ],['Bangladesh',2.4 ],['Lebanon',2.2],
['Finland',2.1 ],['Israel', 2.1],['Macedonia',1.94 ],['Canada',1.7],
['Czech Republic',1.67],['New Zealand',1.41],['Morocco',1.40 ],
['Chile',1.33],['United Kingdom',1.23 ],['Australia',1.16],
['Poland',1.1 ],['Ireland',0.96 ],['Italy',.87 ],['Netherlands',.86 ],
['Sweden',.86],['Denmark',.85],['Germany',.81 ],['Spain',0.72],
['Norway',0.68],['Austria',0.56],['Japan',.35]
]);
- 让我们配置我们的图表选项:
var options = {width:800,height:600};
- 最后但绝不是最不重要的,让我们创建我们的图表:
var chart = new google.visualization.GeoChart(document.getElementById('chart'));
chart.draw(data,options);
}//end of init function
当您加载 HTML 文件时,您会发现世界各国以反映谋杀率的突出颜色显示出来。(我们没有所有世界国家的完整列表,有些国家太小,很难找到它们。)
它是如何工作的...
这个配方的逻辑非常简单,所以让我们快速浏览一下,并添加一些额外的功能。与所有其他可视化图表一样,有三个单独的步骤:
-
定义数据源
-
设置图表
-
绘制图表
并非所有国家都是相同的。如果您在处理一个有轮廓的国家时遇到问题,请搜索最新的 Google 文档,了解支持的国家。您可以在gmaps-samples.googlecode.com/svn/trunk/mapcoverage_filtered.html
上查看完整列表。
还有更多...
让我们对我们的图表添加一些额外的自定义。与所有 Google Visualization 库元素一样,我们可以通过options
对象控制许多可视化效果。
我们地图中突出显示的绿色看起来不对。你会认为杀戮越少,一个国家就会越绿,所以在杀戮更多的地方,更深的红色更合适。所以让我们通过更新options
对象来改变颜色:
var options = {width:800,height:600,
colorAxis: {colors: ['#eeffee', 'red']}
};
使较小的区域更可见
为了解决真正小的不可见的国家的问题,我们可以将我们的渲染切换为基于标记的。我们可以切换到基于标记的渲染模式,而不是突出显示土地本身:
var options = {width:800,height:600,
displayMode: 'markers',
colorAxis: {colors: ['#22ff22', 'red']}
};
默认情况下,当使用标记渲染可视化地图时,当您在压缩区域上滚动时,高亮的缩放视图将帮助创建更清晰的视图:
另一个选择是放大到该区域(我们可以两者都做,或者只是放大)。要放大到一个区域,我们将使用这段代码:
var options = {width:800,height:600,
region:'MX',
colorAxis: {colors: ['#22ff22', 'red']}
};
要了解可能的值列表,请参阅本章前面的国家列表。在这种情况下,我们正在放大到MX
地区:
这涵盖了使用地理图表的基础知识。有关使用 Google Visualization API 的更多信息,请参阅第八章玩转 Google 图表。
获取 Google API 密钥
要使用大多数 Google API,你必须有一个 Google API 密钥。因此,我们将介绍获取 Google API 密钥所涉及的步骤。
Google API 有一些限制和约束。尽管大多数 API 对于中小型网站是免费的,但你仍然受到一些规则的约束。请参考每个库的规则和条例。
准备工作
要完成这个示例,你必须有一个 Google ID;如果你没有,你需要创建一个。
如何做...
让我们列出获得访问 Google API 所需步骤:
-
登录到
code.google.com/apis/console
的 API 控制台。 -
从左侧菜单中选择服务选项:
-
激活你想要使用的 API(例如,在下一个示例构建 Google 地图中,我们将使用 Google Maps API v3 服务):
-
同样,在左侧菜单中选择API 访问选项。您将需要复制API 密钥并在将来的 Google API 项目中替换它:
这是我们唯一一次讨论与 Google API 平台的密钥和权限有关的问题。请验证您已激活密钥,并设置正确的库以便您可以访问。
它是如何工作的...
理解这是如何工作的并不难。你只需要记住这些步骤,因为它们将成为我们创建未来 Google API 交互的基础。
正如你可能已经注意到的,Google 库中有许多 API,我们甚至无法全部涉及,但我建议你浏览一下并探索你的选择。在接下来的几个示例中,我们将使用 Google API 来执行一些与地图相关的任务。
构建 Google 地图
数据和地理有着非常自然的关系。数据在地图上更有意义。使用实时地图是一个非常好的选择,因为它可以让用户与地理区域内集成了您自己数据呈现的 UI 进行交互。在这个示例中,我们将集成我们的第一个真实实时地图。
准备工作
要完成这个示例,你必须有一个 Google ID。如果你没有,你需要创建一个。除此之外,你还需要在 API 控制台中激活 Google Maps API v3 服务。有关更多信息,请参阅本章前面讨论的获取 Google API 密钥示例。
我们的目标是创建一个全屏的 Google 地图,将放大并聚焦在法国:
如何做...
让我们列出创建此示例的步骤。要创建此示例,我们将创建两个文件——一个.html
文件和一个.js
文件:
- 让我们从 HTML 文件开始。我们将为我们的项目创建一个基本的 HTML 文件基线:
<!DOCTYPE html>
<html>
<head>
<title>Google Maps Hello world</title>
<meta charset="utf-8" />
</head>
<body>
<div id="jsmap"></div>
</body>
</html>
- 我们将添加 HTML 视口信息。这是移动设备如何呈现页面的指示(如果您不关心在移动设备上访问地图,可以跳过此步骤):
<head>
<title>Google Maps Hello world</title>
<meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
</head>
- 将样式信息添加到头部:
<style>
html { height: 100% }
body { height: 100%; margin: 0; padding: 0 }
#jsmap { height: 100%; width:100% }
</style>
- 加载 Google Maps v3 API(用您的 API 密钥替换粗体文本):
<script src="img/strong>&sensor=true">
- 添加我们的
09.03.googleJSmaps.js
JavaScript 文件的脚本源:
<script src="img/09.03.googleJSmaps.js"></script>
- 添加一个
onload
触发器,将调用init
函数(这将在下一步中创建):
<body onload="init();">
- 在
09.03.googleJSmaps.js
JavaScript 文件中,添加init
函数:
function init() {
var mapOptions = {
center: new google.maps.LatLng(45.52, 0),
zoom: 7,
mapTypeId: google.maps.MapTypeId.ROADMAP
};
var map = new google.maps.Map(document.getElementById("jsmap"), mapOptions);
}
- 加载 HTML 文件,您应该会发现一个全屏幕的路线图缩放到法国。
它是如何工作的...
最重要和第一步是加载maps
API。为了让 Google 满足您的请求,您必须拥有有效的 API 密钥。因此,请不要忘记用您的密钥替换粗体文本:
<script src="img/strong>&sensor=true">
不要忘记使用您自己的密钥。您可能会发现自己的网站地图出现故障。URL 中的sensor
参数是强制性的,必须设置为true
或false
。如果您的地图需要知道用户位置在哪里,您必须将其设置为true
,如果不需要,可以将其设置为false
。
在我们的应用程序中另一个有趣的事情是,这是我们第一次在示例中使用视口。由于这个主题超出了本书的范围,我想留下来。我知道你们中的许多人最终会在移动设备上使用地图,并希望地图默认为垂直/水平视图。要了解更多有关视口如何工作的信息,请查看此处提供的文章:developer.mozilla.org/en/Mobile/Viewport_meta_tag/
。
您可能已经注意到,我们在我们的 CSS 中设置了许多东西为 100%,正如您可能猜到的那样,这是为了向后兼容性和验证地图将填满整个屏幕。如果您只想创建一个固定的宽度/高度,您可以通过用以下代码替换 CSS 来实现:
<style>
#jsmap { height: 200px; width:300px; }
</style>
这涵盖了我们在 HTML 文件中需要做的主要事情。
还有更多...
我们还没有涵盖init
函数如何工作的细节。init
函数的基本原理非常简单。创建地图只涉及两个步骤。我们需要知道我们希望地图位于哪个div
层,并且我们希望将哪些选项发送到我们的地图:
var map = new google.maps.Map(div,options);
与上一个配方中的 Google 可视化 API 有三个步骤不同,我们可以看到 Google maps
API 只有一个步骤,在其中我们直接发送两个选项以进行渲染(在创建和渲染之间没有步骤)。
让我们更深入地了解选项,因为它们将改变地图的大部分视觉和功能。
使用纬度和经度
纬度和经度(lat/long)是一种将地球划分为网格模式的坐标系统,使得在地球上定位点变得容易。纬度代表垂直空间,而经度代表水平空间。需要注意的是,谷歌使用世界大地测量系统 WGS84 标准。还有其他标准存在,所以如果你的纬度/经度不使用相同的标准,你会发现自己位于一个与最初寻找的位置不同的地方。
基于纬度/经度定位区域的最简单方法是通过我们地图上的辅助工具或搜索主要城市的纬度/经度信息。
www.gorissen.info/Pierre/maps/googleMapLocation.php
将帮助您直接在谷歌地图上点击以定位一个点。在此类别中的另一个选项是在主谷歌地图站点(maps.google.com/
)上打开实验室功能。在屏幕左下角的主谷歌地图站点上,您会找到地图实验室。在那里,您会找到一些纬度/经度助手。
或者您可以通过访问www.realestate3d.com/gps/latlong.htm
按城市搜索数据。
在我们的情况下,当我们准备好做出选择时,我们将更新options center
属性,以反映我们希望地图居中的位置,并调整缩放级别,直到感觉合适:
var mapOptions = {
center: new google.maps.LatLng(45.52, 0),
zoom: 7,
mapTypeId: google.maps.MapTypeId.ROADMAP
};
地图类型
有许多地图类型,甚至可以创建自定义的地图类型,但是对于我们的需求,我们将专注于最常用的基本类型:
-
google.maps.MapTypeId.ROADMAP
:显示谷歌地图的正常、默认的 2D 瓦片 -
google.maps.MapTypeId.SATELLITE
:显示摄影瓦片 -
google.maps.MapTypeId.HYBRID
:显示摄影瓦片和突出特征的瓦片图层(道路、城市名称等) -
google.maps.MapTypeId.TERRAIN
:显示用于显示海拔和水体特征(山脉、河流等)的物理地形瓦片
这涵盖了您需要了解的基础知识,以便将地图集成到网站上。
添加标记和事件
我们屏幕上有地图很棒(假设您已经按照上一篇文章构建谷歌地图),但是如何连接数据并将其集成到我们的地图中呢。我很高兴你问到了这个问题,因为这篇文章将是我们第一步,将数据添加到标记和事件的形式中。
在这个示例中,我们的目标是在纽约市放置四个标记。当点击标记时,我们将放大到该区域并切换地图视图类型。
准备工作
在这个阶段,您应该已经使用 JS API 创建了(至少一次)谷歌地图;如果没有,请回到构建谷歌地图的步骤。
如何做到这一点...
我们不会在上一篇文章构建谷歌地图中创建的 HTML 页面中进行进一步的更改;因此,我们将把注意力集中在 JavaScript 文件上:
- 创建一个
init
函数:
function init(){
//all the rest of logic in here
}
- 在
base
状态中创建地图常量,然后放大到该状态:
function init() {
var BASE_CENTER = new google.maps.LatLng(40.7142,-74.0064 );
var BASE_ZOOM = 11;
var BASE_MAP_TYPE = google.maps.MapTypeId.SATELLITE;
var INNER_ZOOM = 14;
var INNER_MAP_TYPE = google.maps.MapTypeId.ROADMAP;
- 创建默认地图选项:
//40.7142° N, -74.0064 E NYC
var mapOptions = {
center: BASE_CENTER,
zoom: BASE_ZOOM,
mapTypeId: BASE_MAP_TYPE
};
var map = new google.maps.Map(document.getElementById("jsmap"), mapOptions);
- 为我们的点创建数据源:
var aMarkers = [
{label:'New York City',
local: map.getCenter()},
{label:'Brooklyn',
local: new google.maps.LatLng(40.648, -73.957)},
{label:'Queens',
local: new google.maps.LatLng(40.732, -73.800)},
{label:'Bronx',
local: new google.maps.LatLng(40.851, -73.871)},
];
- 循环遍历每个数组元素,并创建一个带有事件的标记,该事件将放大到该位置,切换视图并平移到正确的位置:
var marker;
for(var i=0; i<aMarkers.length; i++){
marker = new google.maps.Marker({
position: aMarkers[i].local,
map: map,
title: aMarkers[i].label
});
google.maps.event.addListener(marker, 'click', function(ev) {
map.setZoom(INNER_ZOOM);
map.panTo(ev.latLng);
map.setMapTypeId(INNER_MAP_TYPE);
});
}
- 最后但并非最不重要的是,使地图可点击。因此,当用户点击地图时,它应该重置为其原始状态:
google.maps.event.addListener(map, 'click', function() {
map.setZoom(BASE_ZOOM);
map.panTo(BASE_CENTER);
map.setMapTypeId(BASE_MAP_TYPE);
});
当您运行应用程序时,您会在屏幕上找到四个标记。当您点击它们时,您将跳转到更深的缩放视图。当您点击空白区域时,它将带您回到原始视图。
工作原理...
与事件和谷歌地图一起工作非常容易。所涉及的步骤始终从调用静态方法google.maps.event.addListener
开始。此函数接受三个参数,即要监听的项目、事件类型(作为字符串)和一个函数。
例如,在我们的for
循环中,我们创建标记,然后为它们添加事件:
google.maps.event.addListener(marker, 'click', function(ev) {
map.setZoom(INNER_ZOOM);
map.panTo(ev.latLng);
map.setMapTypeId(INNER_MAP_TYPE);
});
相反,我们可以创建事件,然后不需要每次循环时重新创建一个新的匿名函数:
for(var i=0; i<aMarkers.length; i++){
marker = new google.maps.Marker({
position: aMarkers[i].local,
map: map,
title: aMarkers[i].label
});
google.maps.event.addListener(marker, 'click', onMarkerClicked);
}
function onMarkerClicked(ev){
map.setZoom(INNER_ZOOM);
map.panTo(ev.latLng);
map.setMapTypeId(INNER_MAP_TYPE);
}
优势真的很大。我们不是为每个循环创建一个函数,而是在整个过程中使用相同的函数(更智能,内存占用更小)。在我们的代码中,我们没有提及任何硬编码的值。相反,我们使用事件信息来获取latLng
属性。我们可以毫无问题地重复使用相同的函数。顺便说一句,您可能已经注意到,这是我们第一次将一个命名函数放在另一个命名函数(init
函数)中。这并不是问题,它的工作方式与变量作用域完全相同。换句话说,我们创建的这个函数只在init
函数范围内可见。
创建标记非常简单;我们只需要创建一个新的google.maps.Marker
并为其分配一个位置和一个地图。所有其他选项都是可选的。(有关完整列表,请查看developers.google.com/maps/documentation/javascript/reference#MarkerOptions
上可用的 Google API 文档。)
还有更多...
您可能已经注意到我们使用了map.panTo
方法,但实际上没有发生平移,一切都会立即到位。如果运行地图,您会发现我们实际上并没有看到任何平移;这是因为我们同时切换了地图类型,缩小了地图,并进行了平移。只有平移可以在没有一些技巧和绕过的情况下实际动画化,但所有这些步骤使我们的应用程序变得更加复杂,对动画的实际控制非常有限。在下一个示例中,我们将提出一个解决方案,因为我们使用了两张地图而不是一张地图自定义控件和重叠地图。如果我们愿意,我们可以添加延迟并分别执行每个步骤并动画化平移,但如果我们想要创建一个平滑的过渡,我会考虑使用两张叠放在一起的地图,然后淡入和淡出主世界地图的想法。
自定义控件和重叠地图
这个示例的目标是练习使用 Google 地图。我们将在本章学到的关于使用 Google 地图的知识,并将我们对用户行为的控制,例如用户可以使用哪些控制器,整合到其中。我们将开始挖掘创建我们自己不支持的未记录的行为,例如锁定用户的平移区域。
在这个示例中,我们的主要任务是将我们在上一个示例中的工作,而不是让地图放大和移动,而是在放大和缩小选项之间创建清晰的过渡;但由于界面不支持以清晰的方式进行,我们将使用外部焦点。这个想法很简单;我们将两张地图叠放在一起,淡入和淡出顶部地图,从而完全控制过渡的流畅性。
准备工作
尽管我们是从头开始的,但我们在上一个示例中所做的大部分工作都被重复使用,因此我强烈建议您在进入本示例之前先阅读上一个示例添加标记和事件。
在这个示例中,我们还将把 jQuery 整合到我们的工作中,以节省我们在创建自己的动画工具上的时间(或者重用我们在第六章中创建的动画独立图层的工具),因为这会让我们偏离主题。
如何做到...
在这个示例中,我们将创建两个文件。一个 HTML 文件和一个 JS 文件。让我们来看看,从 HTML 文件开始:
- 创建一个 HTML 文件并导入 Google
maps
API 和 jQuery:
<!DOCTYPE html>
<html>
<head>
<title>Google Maps Markers and Events</title>
<meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
<script src="img/jquery.min.js"></script>
<script src="img/js?key=AIzaSyAywwIFJPo67Yd4vZgPz4EUSVu10BLHroE&sensor=true"></script>
<script src="img/09.05.controls.js"></script>
</head>
<body onload="init();">
<div id="mapIn"></div>
<div id="mapOut"></div>
</body>
</html>
- 使用 CSS 将地图的图层堆叠在一起:
<style>
html { height: 100% }
body { height: 100%; margin: 0; padding: 0 }
#mapIn, #mapOut { height: 100%; width:100%; position:absolute; top:0px; left:0px }
</style>
- 创建
09.05.controls.js
JS 文件,并在其中创建一个init
函数(从这一点开始,其余的代码将在init
函数中):
function init(){
//rest of code in here
}
- 创建具有自定义信息的两张地图:
var BASE_CENTER = new google.maps.LatLng(40.7142,-74.0064 );
//40.7142° N, -74.0064 E NYC
var mapOut = new google.maps.Map(document.getElementById("mapOut"),{
center: BASE_CENTER,
zoom: 11,
mapTypeId: google.maps.MapTypeId.SATELLITE,
disableDefaultUI: true
});
var mapIn = new google.maps.Map(document.getElementById("mapIn"),{
center: BASE_CENTER,
zoom: 14,
mapTypeId: google.maps.MapTypeId.ROADMAP,
disableDefaultUI: true,
panControl:true
});
- 将标记添加到上层地图:
var aMarkers = [
{label:'New York City',
local: mapOut.getCenter()},
{label:'Brooklyn',
local: new google.maps.LatLng(40.648, -73.957)},
{label:'Queens',
local: new google.maps.LatLng(40.732, -73.800)},
{label:'Bronx',
local: new google.maps.LatLng(40.851, -73.871)},
];
var marker;
for(var i=0; i<aMarkers.length; i++){
marker = new google.maps.Marker({
position: aMarkers[i].local,
map: mapOut,
title: aMarkers[i].label
});
google.maps.event.addListener(marker, 'click', onMarkerClicked);
}
function onMarkerClicked(ev){
mapIn.panTo(ev.latLng);
$("#mapOut").fadeOut(1000);
}
- 将
click
事件添加到内部地图,当您点击它时,将返回到上层地图:
google.maps.event.addListener(mapIn, 'click', function() {
mapIn.panTo(BASE_CENTER);
$("#mapOut").fadeIn(1000);
});
- 使用
center_changed
事件强制用户禁用上层地图中的pan
:
google.maps.event.addListener(mapOut, 'center_changed', function() {
mapOut.panTo(BASE_CENTER);
//always force users back to center point in external map
});
当您加载 HTML 文件时,您会发现一个全屏地图,无法拖动。当您点击标记时,它将淡入所选区域。现在您可以在地图周围拖动光标。下次您在内部地图上点击(在任何区域上进行常规点击)时,地图将再次淡出到原始的上层。
它是如何工作的...
我们最大的一步是创建两个地图,一个重叠在另一个上面。我们通过一些 CSS 魔术来实现这一点,通过叠加元素并将我们的顶层放在堆栈的最后位置(我们可能可以使用 z-index 来验证它,但它有效,所以我没有将其添加到 CSS 中)。之后,我们创建了两个div
层并设置了它们的 CSS 代码。在 JavaScript 代码中,与上一个示例中的方式相反,我们将我们想要的值硬编码到了两个地图中。
在两个地图的选项中,我们将通过将属性disableDefaultUI
设置为true
来设置默认控制器不生效,而在mapIn
中,我们将panControl
设置为true
,以展示地图可以通过平移来移动:
var mapOut = new google.maps.Map(document.getElementById("mapOut"),{
center: BASE_CENTER,
zoom: 11,
mapTypeId: google.maps.MapTypeId.SATELLITE,
disableDefaultUI: true
});
var mapIn = new google.maps.Map(document.getElementById("mapIn"),{
center: BASE_CENTER,
zoom: 14,
mapTypeId: google.maps.MapTypeId.ROADMAP,
disableDefaultUI: true,
panControl:true
});
我们可以通过将布尔值设置为以下任何选项来手动设置所有控制器:
-
panControl
-
zoomControl
-
mapTypeControl
-
streetViewControl
-
overviewMapControl
我们的event
逻辑与上一个示例中的逻辑完全相同。唯一的变化在于实际的监听器中,我们使用 jQuery 在地图之间进行切换:
function onMarkerClicked(ev){
mapIn.panTo(ev.latLng);
$("#mapOut").fadeOut(1000);
}
google.maps.event.addListener(mapIn, 'click', function() {
mapIn.panTo(BASE_CENTER);
$("#mapOut").fadeIn(1000);
});
在标记的事件和地图的click
事件中,我们使用 jQuery 的fadeIn
和fadeOut
方法来动画显示我们外部地图的可见性。
还有更多...
当您尝试在高级地图(第一个可见地图)周围拖动时,您会注意到地图无法移动——它是不可平移的。Google API v3 不支持禁用平移的功能,但它支持在地图中心点更改时每次获得更新。
因此,我们监听以下更改:
google.maps.event.addListener(mapOut, 'center_changed', function() {
mapOut.panTo(BASE_CENTER);
});
我们所做的就是每次地图位置发生变化时,强制将其恢复到原始位置,使我们的地图无法移动。
使用样式重新设计地图
在使用 Google Maps 创建更高级的应用程序时,您经常会希望创建自己的自定义样式地图。当您希望拥有前景内容并且不希望它与背景内容竞争时,这是非常有用的。
在本示例中,我们将创建一些样式化地图。在本示例结束时,您将知道如何创建全局定制、个体样式,以及添加新地图类型。
这是我们将创建的一个样式:
这是我们将创建的第二个样式:
准备工作
要完成本示例,您需要从上一个示例的副本开始。我们只描述与本示例中上一个示例不同的新步骤。要查看和理解所有步骤,请阅读自定义控件和重叠地图示例。
因此,我们将跳过 HTML 代码,因为它与上一个示例中的代码完全相同。
如何做到...
打开上一个示例中的 JavaScript 文件(09.05.controls.js
),并按照以下步骤操作:
- 在
init
函数中创建一个aVeinStyle
数组。该数组包含了所有用于定制地图样式的视觉指南:
var aVeinStyle = [
{
featureType:'water',
elementType: "geometry",
stylers:[{color:'#E398BF'}]
},
{
featureType:'road',
elementType: "geometry",
stylers:[{color:'#C26580'}]
},
{
featureType:'road.arterial',
elementType: "geometry",
stylers:[{color:'#9B2559'}]
},
{
featureType:'road.highway',
elementType: "geometry",
stylers:[{color:'#75000D'}]
},
{
featureType:'landscape.man_made',
elementType: "geometry",
stylers:[{color:'#F2D2E0'}]
},
{
featureType:'poi',
elementType: "geometry",
stylers:[{color:'#C96FB9'}]
},
{
elementType: "labels",
stylers:[{visibility:'off'}]
}
];
- 创建一个名为
Veins
的新google.maps.StyledMapType
地图:
var veinStyle = new google.maps.StyledMapType(aveinStyle,{name: "Veins"});
- 创建一个公交样式:
var aBusStyle = [
{
stylers: [{saturation: -100}]
},
{
featureType:'transit.station.rail',
stylers:[{ saturation: 60},{hue:'#0044ff'},{visibility:'on'}]
}
];
var busStyle = new google.maps.StyledMapType(aBusStyle,{name: "Buses"});
- 对于内部地图,使地图类型控制器可见,并在其中包括我们新地图样式的 ID:
var mapIn = new google.maps.Map(document.getElementById("mapIn"),{
center: BASE_CENTER,
zoom: 14,
mapTypeId: google.maps.MapTypeId.ROADMAP,
disableDefaultUI: true,
panControl:true,
mapTypeControl:true,
mapTypeControlOptions: {
mapTypeIds: [google.maps.MapTypeId.ROADMAP, 'veinStyle', 'busStyle']
}
});
- 将地图样式信息添加到
mapIn
对象中:
mapIn.mapTypes.set('veinStyle', veinStyle);
mapIn.mapTypes.set('busStyle', busStyle);
- 设置默认地图类型:
mapIn.setMapTypeId('busStyle');
当您重新启动 HTML 文件中的内部地图(在单击标记中的一个后),您将找到一个控制器菜单,可以在自定义地图类型之间切换。
它是如何工作的...
使用 Google 样式很有趣,它们的工作方式与 CSS 非常相似。我们设置的样式有几个步骤;第一步是创建样式的规则,下一步是定义一个 Google 样式对象(google.maps.StyledMapType
),最后一步是定义这个样式信息与哪个地图相关联。样式只能应用于google.maps.MapTypeId.ROADMAP
类型的地图。
第一个示例是创建公交车样式。这种样式的目标是使地图变成黑白色,并只突出显示公共交通站点:
var aBusStyle = [
{
stylers: [{saturation: -100}]
},
{
featureType:'transit.station.rail',
stylers:[{ saturation: 60},{hue:'#0044ff'},{visibility:'on'}]
}
];
var busStyle = new google.maps.StyledMapType(aBusStyle,{name: "Buses"});
第一个变量是一个常规数组。我们可以添加任意多个样式;每次我们想要定义规则(搜索条件)之前,都会应用这些规则。让我们更深入地看一下一个样式规则:
{stylers: [{saturation: -100}]}
这个例子是最基本的。我们没有规则,或者换句话说,我们想将这种样式应用到所有东西上。就像在这个例子中,我们将饱和度设置为-100
,我们正在使一切变成黑白色(饱和度默认值为0
,可以取值在-100
和100
之间)。
可能的样式属性如下:
-
可见性
:这是一个字符串值(no
,off
或simplified
)。这会向地图添加或移除元素;在大多数情况下,它将用于根据提供的信息删除文本,如标签和细节。 -
伽马
:这是一个介于0.01
和10
之间的数字值(默认值为1.0
)。这个选项控制视图中有多少光。较低的值(低于1
)会加强较浅和较暗颜色之间的差异,较高的数字(大于1
)会产生更全局的效果,使一切随着数值的增加而更加发光。 -
色调
:这是一个十六进制颜色值,包装成字符串(例如#222222
)。最好的描述色调的方式是,想象戴上与提供的十六进制值匹配的有色玻璃的太阳镜。有色玻璃如何影响你周围的颜色并改变它们的方式,就像地图的色调颜色改变的方式一样。 -
亮度
:这是一个介于-100
和100
之间的值(默认值为0
)。如果提供一个小于0
的值,这个效果就非常简单。这与在地图上放置一个黑色矩形并改变其不透明度的效果相同(即,-30
将与 30%的不透明度相匹配)。你可能已经猜到了正值的结果——对于正值,想法是一样的,但只是用一个白色矩形。 -
饱和度
:这是一个介于-100
和100
之间的值(默认值为0
)。这个效果侧重于像素级的值,-100
会创建更接近100
的灰度图像值。它会从图像中去除所有灰色,使一切更加生动。
这就是所有可用的样式信息,有了它,我们可以控制地图内的每个样式元素。每个样式属性的信息都需要作为stylers
数组中的单独对象发送;例如,如果我们想要在我们的片段中添加一个色调
,它会看起来像这样:
{stylers: [{saturation: -40},{hue:60}]}
现在我们知道了可以改变地图视觉效果的所有不同方式,是时候了解我们将如何定义应该被选择的内容。在最后的代码片段中,我们控制了整个地图,但我们可以通过添加过滤逻辑来过滤我们想要控制的内容:
{elementType: "geometry",
stylers:[{color:'#E398BF'}]
在这个片段中,我们正在过滤我们想要改变所有geometry
元素的颜色,这意味着不是geometry
元素的任何东西都不会受到影响。有三种类型的元素类型选项:
-
全部
(默认选项) -
几何
-
标签
还有一种过滤信息的方法,就是使用featureType
属性。例如:
{
featureType:'landscape.man_made',
elementType: "geometry",
stylers:[{color:'#F2D2E0'}]
}
在这种情况下,我们正在列出我们想要关注的内容。我们想关注特征类型和元素类型。如果我们提取elementType
属性,我们的颜色效果将影响geometry
和labels
。而如果我们提取featureType
,它将影响地图中的所有geometry
元素。
有关featureType
属性选项的完整列表,请访问goo.gl/H7HSO
。
还有更多...
现在我们已经掌握了如何创建我们想要使用的样式,下一个关键步骤是实际将我们的样式与地图连接起来。最简单的方法(如果我们只有一个样式)是直接将其连接到地图上:
inMap.setOptions({styles: styles});
这可以通过调用setOptions
函数或在创建地图时添加style
属性来完成。样式只能添加到路线图中,因此如果将此样式添加到不是路线图的地图上,它将不会被应用。
由于我们想要添加多个样式选项,我们必须列出地图类型。在这之前,我们需要使用以下代码创建一个新的地图类型对象:
var busStyle = new google.maps.StyledMapType(aBusStyle,{name: "Buses"});
在创建新地图时,我们提供了一个名称,该名称将用作我们在控制器中的名称 - 如果我们选择创建一个控制器(在我们的示例中我们会这样做)。重要的是要注意,这个名称不是我们元素的 ID,而只是元素的标签,我们仍然需要在将其发送到地图之前为我们的元素创建一个 ID。为此,我们将首先将 ID 添加到我们的控制器中,并使我们的控制器可见:
var mapIn = new google.maps.Map(document.getElementById("mapIn"),{
center: BASE_CENTER,
zoom: 14,
mapTypeId: google.maps.MapTypeId.ROADMAP,
disableDefaultUI: true,
panControl:true,
mapTypeControl:true,
mapTypeControlOptions: {
mapTypeIds: [google.maps.MapTypeId.ROADMAP, 'veinStyle', 'busStyle']
}
});
在此之后,我们将添加设置指令,将我们的新地图类型连接到它们的样式对象:
mapIn.mapTypes.set('veinStyle', veinStyle);
mapIn.mapTypes.set('busStyle', busStyle);
最后但同样重要的是,我们可以将默认地图更改为我们的样式地图之一:
mapIn.setMapTypeId('busStyle');
就是这样。现在你已经知道了如何在谷歌地图中使用样式的所有必要信息。
第十章:地图行动
在本章中,我们将涵盖以下主题:
-
将 Twitter 动态连接到 Google 地图
-
构建一个高级交互式标记
-
将多条推文添加到信息窗口气泡中
-
自定义标记的外观和感觉
-
最终项目:构建实时行程
介绍
在这一章中,我们将更深入地与数据可视化这一主题联系起来。如今,可视化数据最流行的方式之一是使用地图。在本章中,我们将探讨如何将数据集成到地图中,使用 Google 地图平台。
将 Twitter 动态连接到 Google 地图
这是一个非常有趣的与 Google 地图的实验的开始。任务的目标是在 Twitter 帖子和 Google 地图之间创建一个链接。我们需要几个配方才能达到最终目标。在本配方结束时,我们将拥有一个 Google 地图。这个 Google 地图在屏幕的任何区域都可以点击。当用户点击地图时,他们将连接到 Twitter API,并搜索该区域中包含“HTML5”一词的推文。当结果返回时,它将在被点击的区域上弹出一个新的标记,并添加来自该位置的关于该主题的最新推文。在这个阶段,它只是一个带有悬停效果的标记,显示我们的实际推文而没有更多信息。
准备工作
如果你还没有阅读第九章使用 Google 地图,你可能会发现本章有点困难,所以我鼓励你在开始本章之前阅读它。在这个阶段,你应该已经设置了 Google API(参见第九章中的获取 Google API 密钥配方)。
如何做...
我们将创建新的 HTML 和 JavaScript 文件,并分别称它们为10.01.socielmap.html
和10.01.socielmap.js
,然后执行以下步骤:
- 在 HTML 文件中添加以下代码,使用你自己的 API 密钥:
<!DOCTYPE html>
<html>
<head>
<title>Google Maps Markers and Events</title>
<meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
<style>
html { height: 100% }
body { height: 100%; margin: 0; padding: 0 }
#map { height: 100%; width:100%; position:absolute; top:0px; left:0px }
</style>
<script src="img/jquery.min.js"></script>
<script src="img/js?key=AIzaSyAywwIFJPo67Yd4vZgPz4EUSVu10BLHroE&sensor=true">
</script>
<script src="img/10.01.socielmap.js"></script>
</head>
<body onload="init();">
<div id="map"></div>
</body>
</html>
- 让我们进入 JavaScript 文件。由于
onload
事件触发时会调用init()
函数,我们将把所有代码放在一个新的init
函数中。
function init(){
//all code here
}
- 我们将从设置地图的中心点开始。到目前为止,我们一直非常关注我的家乡纽约州,所以让我们把注意力转向欧洲。
var BASE_CENTER = new google.maps.LatLng(48.516817734860105,13.005318750000015 );
- 接下来,让我们为地图创建一个黑白风格,这样更容易专注于我们即将创建的标记。
var aGray = [
{
stylers: [{saturation: -100}]
}
];
var grayStyle = new google.maps.StyledMapType(aGray,{name: "Black & White"});
- 创建一个新的 Google 地图。
var map = new google.maps.Map(document.getElementById("map"),{
center: BASE_CENTER,
zoom: 6,
mapTypeId: google.maps.MapTypeId.ROADMAP,
disableDefaultUI: true,
});
- 设置
grayStyle
样式对象为我们的默认样式。
map.mapTypes.set('grayStyle', grayStyle);
map.setMapTypeId('grayStyle');
- 我们的下一步是使用 Google 的 API 为地图创建一个新的
click
事件。当地图被点击时,我们希望触发一个listener
函数。当发生点击时,我们希望开始我们的 Twitter 搜索,因为我们将连接到 Twitter API,并搜索关键词html5
在我们点击地图的位置的 50 公里半径内的提交。让我们创建一个新的鼠标事件并启动 Twitter 搜索。
google.maps.event.addListener(map, 'click', function(e) {
//console.log(e.latLng);
var searchKeyWord = 'html5';
var geocode=e.latLng.lat() + "," + e.latLng.lng()+",50km";
var searchLink = 'http://search.twitter.com/search.json?q='+ searchKeyWord+ '&geocode=' + geocode +"&result_type=recent&rpp=1";
$.getJSON(searchLink, function(data) {
showTweet(data.results[0],e.latLng);
});
});
- 当 Twitter 搜索值返回时,是展示我们的新推文的时候;如果没有找到推文,我们将放入默认内容,让用户知道找不到任何内容。
function showTweet(obj,latLng){
if(!obj) obj = {text:'No tweet found in this area for this topic'};
console.log(obj);
var marker = new google.maps.Marker({
map: map,
position: latLng,
title:obj.text });
}
当你再次加载地图时,你会发现一个欧洲地图等待点击。每次点击都会触发一个新的 Twitter 搜索,并根据你点击的位置生成一个新的结果。要阅读推文,请在返回后将鼠标悬停在标记上。
它是如何工作的...
我们生活在一个几乎任何数据都与地理位置数据和地图重叠的时代。几乎不可能写一本关于数据的书而不谈论地图,也不可能写一本关于数据可视化的书而不至少打开地图世界和其可能性的潘多拉魔盒。
最近,Twitter 一直在努力捕获用户的位置。大部分时间,位置仍然是空的。话虽如此,Twitter 始终根据用户的信息知道用户的基本位置,尤其是当用户通过他们的手机发送推文时。因此,Twitter 总是大致知道用户发送消息时的位置,未来几年,这种准确性预计只会变得更好。在未来,越来越多的 Twitter 结果将为用户提供如此准确的位置,以至于我们将能够直接在地图上找到他们。
创建地图后,第一步是,一旦用户点击地图的任何区域,我们就开始构建要在 Twitter API 上使用的搜索查询:
google.maps.event.addListener(map, 'click', function(e) {
//console.log(e.latLng);
var searchKeyWord = 'html5';
var geocode=e.latLng.lat() + "," + e.latLng.lng()+",50km";
var searchLink = 'http://search.twitter.com/search.json?q='+ searchKeyWord+ '&geocode=' + geocode +"&result_type=recent&rpp=1";
我们不涵盖搜索的所有可能性,而是专注于两个主要点:搜索查询,在我们的案例中是 HTML5,以及查询的位置。我们直接从传递给标记的事件中获取位置信息。我们重新格式化来自我们的 Google 返回事件的信息,并将其格式化为一个字符串,添加到其中的范围;在我们的案例中,我们将其设置为 50 公里(您也可以选择ml
表示英里)。由于我们现在正在查看欧洲地图,我认为使用公里而不是英里会更合适。
我们希望将我们的搜索值作为JavaScript 对象表示(JSON)值返回。 JSON 是在服务器之间以字符串形式传递对象信息的一种非常简洁的方式。在大多数情况下,您通常会使用自动转换器,因此您将发送对象并获取对象,但在幕后有一个 JSON 编码器和解码器来处理请求。
注意
如果您不知道 JSON 是什么,不用担心;这一切都是在后台完成的,不必理解 JSON 的工作原理才能使用它。
我们希望以 JSON 格式获取我们的数据;为此,我们将将我们的 URL 参数发送到以下 URL:
search.twitter.com/search.json
附加到其中我们的q
值和geocode
值。如果您想更深入地探索 Twitter 搜索 API 的选项和可能性,请访问以下页面:
dev.twitter.com/docs/api/1/get/search
下一步是将我们的信息发送到此服务并获取我们的结果。为此,我们将使用 jQuery 中的$.getJSON
函数。此函数将处理我们所有的需求:发送我们的请求,获取它,然后将信息解码为常规的 JavaScript 对象。
$.getJSON(searchLink, function(data) {
showTweet(data.results[0],e.latLng);
});
我们需要发送的两个参数是搜索链接和返回函数。在我们的案例中,我们将获取我们的数据并将其发送到外部函数showTweet
。我们将仅发送数据的第一个结果以返回和我们从点击事件中获取的e.latLng
对象信息。
是时候创建标记了。在showTweet
函数中,我们的第一个任务是检查返回的第一个元素中是否实际上有任何数据。如果没有值,这意味着 Twitter 没有找到任何内容。
if(!obj) obj = {text:'No tweet found in this area for this topic'};
如果没有返回对象,我们将创建一个新对象,其中包含占位符信息,以替换常规结果信息。这是避免代码复杂性的好方法:通过将异常构建到常规用户体验中。我们完成了;我们所要做的就是创建标记。
var marker = new google.maps.Marker({
map: map,
position: latLng,
title:obj.text });
尽管我们得到了我们心中所想的,但我们拥有的latLng
信息是我们点击的位置,而不是我们推文的确切位置。目前返回的 Twitter 对象中有一个名为 geo 的属性。在撰写本书时,它总是返回为空。目前看起来它是一个即将发布或部分实现的功能,所以在阅读本书时,请尝试检查并查看obj.geo
属性是否返回一个值,并在此信息可用时使用它使您的观点更准确。
构建一个高级交互式标记
我们社交地图项目的下一步是为我们的 Twitter 搜索结果添加更多细节。当 Twitter 结果出现时,我们希望自动打开一个信息面板。在这个过程中,我们将创建一个 Google 标记的子类,并扩展它,并添加一个新的 InfoWindow,使我们能够将实时 HTML 数据直接添加到我们的地图中。
准备工作
如果您没有从本章的开头开始,加入会很困难。由于这个配方是上一个配方的延续,我们不会创建新的 HTML 文件或新的 JavaScript 文件,而是会从我们离开的地方继续。
操作步骤...
拿出你最新的 JavaScript 文件,让我们继续下一步:
- 在
showTweet
函数中,用一个新的TwitterMarker
标记替换新标记。
function showTweet(obj,latLng){
if(!obj) obj = {text:'No tweet found in this area for this topic'};
console.log(obj);
var marker = new TwitterMarker({
map: map,
position: latLng,
tweet: obj,
title:obj.text });
}
- 现在我们不再使用常规的内置标记,是时候为我们创建自己的标记了。让我们从构造函数开始。
function TwitterMarker(opt){
var strTweet = this.buildTwitterHTML(opt.tweet);
this.infoWindow = new google.maps.InfoWindow({
maxWidth:300,
content:strTweet
});
this.setValues(opt);
this.infoWindow.open(this.map,this);
google.maps.event.addListener(this, 'click', this.onMarkerClick);
}
- 我们将要从
google.maps.Marker
标记中扩展我们的新对象,以便我们可以拥有常规标记的所有功能。
TwitterMarker.prototype = new google.maps.Marker();
- 让我们在标记事件监听器中创建一个切换按钮。当调用事件时,它将打开或关闭我们的 InfoWindow:
TwitterMarker.prototype.onMarkerClick=function(evt){
this.isOpen=!this.isOpen;
if(this.isOpen)
this.infoWindow.close();
else
this.infoWindow.open(this.map,this);
}
- 是时候通过创建 HTML 字符串来创建 Twitter 消息了。
TwitterMarker.prototype.buildTwitterHTML = function(twt){
var str;
if(twt.from_user_name){
str = "<span><img style='float: left' src='"+twt.profile_image_url+"' />"+
"<b>" +twt.from_user_name + "</b><br/><a href ='http://twitter.com/"
+ twt.from_user + "'>@"+twt.from_user+"</a><br/> "
+ twt.location + "</span>"
+ "<p>"+twt.text+"</p>";
}else{
str="The 50 Kilometer radius around this point did not message this value";
}
return str;
}
如果重新加载 HTML 文件,您会发现它在世界的任何地方都是交互式的;如果它能找到一条推文,它将在地图上输出它。
工作原理...
尽管代码行数不多,但其中包含了许多逻辑。让我们从新标记开始。这是本书中第一次使用继承。继承,顾名思义,使我们能够在 JavaScript 中扩展对象的功能,而不影响原始对象。在我们的情况下,我们希望获取标记的所有功能(方法、属性等),并为它们添加一些自定义行为。
在 JavaScript 中,通过定义原型来完成继承。到目前为止,我们使用原型而没有过多地谈论它,但我们主要用它来创建新方法。如果我们将一个完整的对象分配给原型,那么该对象的所有属性和方法也将被复制到我们的新对象中。
TwitterMarker.prototype = new google.maps.Marker();
注意
始终首先通过扩展要扩展的对象来开始,然后再进行任何其他添加。这是因为如果在这行代码之前放置了任何新的原型方法,它们将被静默删除,因此将无法工作。
buildTwitterHTML
方法接收返回的 Twitter 对象并将其数据转换为 HTML。我们每个标记使用此方法一次。当我们创建一个新的标记时,我们也创建一个新的InfoWindow
对象。我们将 InfoWindow 放在标记的顶部,并展示推文信息。
function TwitterMarker(opt){
var strTweet = this.buildTwitterHTML(opt.tweet)
this.infoWindow = new google.maps.InfoWindow({
maxWidth:300,
content:strTweet
});
我们还设置了宽度,以避免出现一个非常大的面板。我们将新创建的strTweet
字符串发送到infoWindow
对象中。
我们希望我们的标记是一个切换按钮,用于控制 InfoWindow 的状态。为此,我们添加了一个新动态创建的名为isOpen
的属性。即使我们在构造函数中打开了 InfoWindow,我们也没有在那里设置isOpen
的值。我们可以在标记的点击事件监听器中执行的第一个操作中解决这个问题。
TwitterMarker.prototype.onMarkerClick=function(evt){
this.isOpen=!this.isOpen;
当点击标记时,我们会自动改变isOpen
变量的状态。因为它之前没有设置,现在将被设置为true
。!
运算符是一个布尔运算符,可以在true
和false
之间切换值。它的实际含义是not
。换句话说,我们是在说:
this.isOpen = is not this.isOpen
有趣的事实是,对于非(!
)运算符的未定义值(未定义的变量的值)与false
、null
甚至0
是相同的。任何其他值都被视为 true。这样,每次点击标记时,变量this.isOpen
的值都会切换。这就是我们切换按钮逻辑的核心。现在只剩下决定是打开还是关闭 InfoWindow。
if(this.isOpen)
this.infoWindow.close();
else
this.infoWindow.open(this.map,this);
}
这将引导我们进入最后一步,找出我们的 Twitter 文本是什么。我们将在接下来的几个步骤中编辑这个方法。您可以随意尝试,并根据自己的喜好进行个性化。我们有两种可能的结果:搜索区域中没有 Twitter 消息和搜索区域中有 Twitter 消息。如果有消息,我们将使用一些返回对象属性来构建一个 HTML 大纲,该大纲将用于与此标记相关联的infoWindow
对象内。如果没有结果,我们将创建一个。
TwitterMarker.prototype.buildTwitterHTML = function(twt){
var str;
if(twt.from_user_name){
//build custom message
/*notice we are validating based on checking if the twitter has a property (any of the properties would work) */
}else{
//the error message
str="The 50 Kilometer radius around this point did not message this value";
}
return str;
}
就是这样!我们的社交地图开始变得更加有趣。它仍然缺少一些功能。如果我们可以在 InfoWindow 结果中看到多条消息,那将是非常好的。在下一个步骤中,我们将尝试解决这个问题。
将多条推文添加到 InfoWindow 气泡中
到目前为止,在我们的交互式社交地图中,我们在每个点击的位置添加了标记,并打开了一个包含推文信息的 InfoWindow。我们的下一步将是通过在窗口中添加分页系统,使多条推文可以存在于我们的 InfoWindow 中。
准备工作
要完成这个步骤,您必须深入了解我们的整体章节。如果您刚刚加入,最好回到本章的开头,因为我们将从上一个步骤离开的地方继续进行。
操作步骤如下:
我们仍然在我们的 JavaScript 文件中,并将继续添加代码和调整我们的代码,以便将多个 Twitter 帖子添加到我们的社交地图中。
- 让我们首先将 Twitter 搜索更改为每次搜索返回最多 100 个值。我们这样做是因为我们调用 Twitter API 的次数是有限的。因此,我们将尝试一次性获取尽可能多的内容(此代码应该在第 30 行左右)。
var searchLink = 'http://search.twitter.com/search.json?q='+ searchKeyWord+ '&geocode=' + geocode +"&result_type=recent&rpp=100";
- 由于我们现在要处理返回的所有推文,我们需要更改我们发送到
TwitterMaker
标记的引用,发送完整的数组(代码片段中的更改已突出显示)。
google.maps.event.addListener(map, 'click', function(e) {
//console.log(e.latLng);
var searchKeyWord = 'html5';
var geocode=e.latLng.lat() + "," + e.latLng.lng()+",50km";
var searchLink = 'http://search.twitter.com/search.json?q='+ searchKeyWord+ '&geocode=' + geocode +"&result_type=recent&rpp=100";
$.getJSON(searchLink, function(data) {
showTweet(data.results,e.latLng);
});
});
function showTweet(a,latLng){
if(!a) a = [{text:'No tweet found in this area for this topic'}];
//console.log(obj);
var marker = new TwitterMarker({
map: map,
position: latLng,
tweet: a,
title:a[0].text });
}
}
- 我们希望更新
TwitterMarker
构造函数,包括我们的数组以及有关它的快速信息,例如总推文数和当前所在的推文。我们需要一种方法来标识我们的对象,因此我们也会给它一个 ID(在接下来的几个步骤中会详细介绍)。
function TwitterMarker(opt){
this.count = opt.tweet.length;
this.crnt = 0;
this.id = TwitterMarker.aMarkers.push(this);
this.aTweets = opt.tweet;
var strTweet = this.buildTwitterHTML(opt.tweet[0])
this.infoWindow = new google.maps.InfoWindow({
maxWidth:300,
content:strTweet
});
this.setValues(opt);
this.infoWindow.open(this.map,this);
google.maps.event.addListener(this, 'click', this.onMarkerClick);
}
- 我们希望在我们的代码中的任何地方都可以访问一个静态数组,其中包含所有创建的标记。为此,我们将在
TwitterMarker
类中添加一个新的状态数组:
TwitterMarker.prototype = new google.maps.Marker();
TwitterMarker.aMarkers= [];
- 在
buildTwitterHTML
方法中,我们希望添加回/下一个链接,用户可以从 InfoWindow 中看到:
TwitterMarker.prototype.buildTwitterHTML = function(twt){
var str;
if(twt.from_user_name){
str = "<span><img style='float: left' src='"+twt.profile_image_url+"' />"+
"<b>" +twt.from_user_name + "</b><br/><a href ='http://twitter.com/"
+ twt.from_user + "'>@"+twt.from_user+"</a><br/> "
+ twt.location + "</span>"
+ "<p>"+twt.text+"</p>";
if(this.count>1){
str+="<span style='absolute; bottom: 0;
right: 0px; width:80px'>";
if(this.crnt!=0) str+="<a href='javascript:TwitterMarker.aMarkers["+(this.id-1)+"].prev();'><</a> ";
str+= (this.crnt+1) + " of " + this.count;
if(this.crnt<(this.count-1)) str+= "<a href='javascript:TwitterMarker.aMarkers["+(this.id-1)+"].next();'>></a> ";
str+= "</span>"
}
}else{
str="The 50 Kilometer radius around this point did not message this value";
}
return str;
}
- 现在让我们添加
next
和prev
方法。
TwitterMarker.prototype.next =function(){
this.infoWindow.close();
this.infoWindow.content = this.buildTwitterHTML(this.aTweets[++this.crnt]);
this.infoWindow.open(this.map,this);
return false;
}
TwitterMarker.prototype.prev =function(){
this.infoWindow.close();
this.infoWindow.content = this.buildTwitterHTML(this.aTweets[--this.crnt]);
this.infoWindow.open(this.map,this);
return false;
}
加载 HTML 文件,您应该会发现一个可以容纳每次点击最多 100 条推文的工作 InfoWindow。
工作原理...
我们的第一个改变是改变了从 Twitter 搜索 API 返回的结果数量。这个改变迫使我们改变了代码中的引用,不再直接引用第一个返回的对象,而是专注于完整的结果对象,并将其发送到我们的TwitterMarker
构造函数。这个改变也在构造函数内的信息流中创建了一些较小的改变。
我们的目标是创建两个按钮,这些按钮将更新我们的 InfoWindow。这是一个问题,因为我们需要标记和其 InfoWindow 之间的双向连接。到目前为止,我们与 InfoWindow 的所有通信都是单向的。我们解决这个问题并绕过 Google 接口的最简单方法是创建一个静态数组,该数组将存储所有标记,并在InfoWindow
对象内部触发按钮时引用我们的静态标记。我们只需要向我们的类名添加一个变量 direction。
TwitterMarker.aMarkers= [];
通过直接将变量添加到TwitterMarker
类中,我们现在可以在任何时候直接引用它,并且它不会在我们的对象中重复(因为它不是原型的一部分)。现在我们有了一个数组,是时候回到我们的TwitterMarker
构造函数中,每次创建一个新的TwitterMarker
对象时,都向这个数组发送一个新的引用。我们通过这样做得到的另一个好处是,我们自动获得一个唯一的标识符(ID),因为返回的数字将始终是我们需要的唯一数字。
this.id = TwitterMarker.aMarkers.push(this);
在这一行代码中,我们执行了前一段中讨论的所有任务。数组push
方法返回数组的新长度。
现在我们有了一种引用我们的标记并获得标识符的方法,是时候回到buildTwitterHTML
方法中,并在呈现的 HTML 中添加两个href
按钮,当单击下一个/上一个选择时,将触发正确的标记。
在我们深入研究之前,我们要检查并验证我们是否有多于一个返回的 Twitter 消息;如果没有,添加新逻辑就没有意义,如果我们为只有一个项目的项目引入了下一个/上一个逻辑,我们将引入一个错误。
if(this.count>1){
}
通过以下if
语句,我们可以确定我们当前是否在第一个 Twitter 消息中,如果不是,我们将添加返回按钮:
if(this.crnt!=0) str+="<a href='javascript:TwitterMarker.aMarkers["+(this.id-1)+"].prev();'><</a> ";
这可能看起来很混乱,但是,如果我们忽略 HTML,专注于当按钮被按下时将触发的实际 JavaScript,我们将得到这样的结果:
TwitterMarker.aMarkers["+(this.id-1)+"].prev();
this.id-1
参数将被实际的当前数字替换:
由于这是渲染为要解析为 HTML 的字符串,将集成到 HTML 中的值将是硬编码的。让我们看一个真实案例来澄清这一点。第一个数组 ID 将是0
,因此prev
按钮将如下代码语句所示:
TwitterMarker.aMarkers[0].prev();
现在逻辑开始显露出来。通过从数组中抓取我们当前元素的标记,我们所要做的就是触发prev
方法并让它接管。
对于另一端,同样的逻辑发生了。唯一的条件是我们不在最后的 Twitter 结果中,如果不是,我们调用next
方法:
if(this.crnt<(this.count-1)) str+= "<a href='javascript:TwitterMarker.aMarkers["+(this.id-1)+"].next();'>></a> ";
到此为止!我们的逻辑核心已经就位。
如果我们愿意,我们可以通过用一个唯一 ID 包装<div>
标签来创建我们的 InfoWindow,并直接调用它并直接更新我们的内容(尝试自己做这个,因为那将是一个更好的解决方案)。相反,我们正在处理 InfoWindow 的限制。由于我们不能在打开时更新完整的 bucket 容器,我们需要关闭它以更新它,然后再次打开它。因此,我们在next
和prev
方法中的逻辑是相似的;两者都对正在呈现的实际值的更改有限制。
TwitterMarker.prototype.next =function(){
this.infoWindow.close();
this.infoWindow.content = this.buildTwitterHTML(this.aTweets[++this.crnt]);
this.infoWindow.open(this.map,this);
return false;
}
TwitterMarker.prototype.prev =function(){
this.infoWindow.close();
this.infoWindow.content = this.buildTwitterHTML(this.aTweets[--this.crnt]);
this.infoWindow.open(this.map,this);
return false;
}
所有的逻辑都是相同的,并且限制在高亮显示的代码片段中。如果你不熟悉这个快捷方式,当++
和--
操作符设置在变量之前时,它们使我们能够在变量上加/减 1,并在发送其值之前更新它。因此,在一行中,我们既可以改变变量中的数字,又可以发送新创建的数字以继续其任务。
在next
方法的情况下,我们想抓取下一个推文,而在prev
方法的情况下,我们想抓取上一个推文。
自定义标记的外观和感觉
这将是我们社交地图的最后一个配方。在这个配方中,我们将重新审视我们的标记本身,并对其进行改头换面。由于我们的标记代表了点击区域的 Twitter 消息,我们将更新我们的标记,使其看起来像 Twitter 鸟(手工制作)。我们不会止步于此;在更新我们的图形后,我们将添加另一个图形层来阴影我们的 Twitter 标记。它将是一个阴影,其不透明度将根据推文数量(最多一百条推文)从零到完全不同。
了解我们的目标的最佳方法是查看以下屏幕截图:
请注意,一些推文没有可见的圆形轮廓,而另一些则有非常深的轮廓(这取决于推文的数量)。
准备工作
要完成此任务,您需要首先完成本章中的所有先前的配方。
操作步骤...
我们将直接进入 JavaScript 文件,并从上一个步骤离开的地方继续。
- 更新
showTweet
函数。
function showTweet(a,latLng){
if(!a) a = [{text:'No tweet found in this area for this topic'}];
//console.log(obj);
var marker = new TwitterMarker({
map: map,
position: latLng,
tweet: a,
title:a[0].text,
icon:"img/bird.png" });
}
- 在
TweeterMarker
构造函数中创建MarkerCounter
对象的实例。
function TwitterMarker(opt){
this.count = opt.tweet.length;
this.mc = new MarkerCounter(opt);
this.crnt = 0;
...
- 创建
MarkerCounter
构造函数。
function MarkerCounter(opt) {
this.radius = 15;
this.opacity = (opt.tweet.length) /100;
this.opt = opt;
this.setMap(opt.map);
}
- 为
google.maps.OverlayView
对象创建子类MarkerCounter
。
MarkerCounter.prototype = new google.maps.OverlayView();
- 创建
onAdd
方法。当元素添加到地图中时,它将自动调用。在此方法中,我们将完成所有绘制的准备工作,但不会绘制元素。
MarkerCounter.prototype.onAdd = function() {
var div = document.createElement('div');
div.style.border = "none";
div.style.borderWidth = "0px";
div.style.position = "absolute";
this.canvas = document.createElement("CANVAS");
this.canvas.width = this.radius*2;
this.canvas.height = this.radius*2;
this.context = this.canvas.getContext("2d");
div.appendChild(this.canvas);
this.div_ = div;
var panes = this.getPanes();
panes.overlayLayer.appendChild(div);
}
- 最后但并非最不重要的是,现在是时候重写
draw
方法,并在上一步创建的新画布元素中绘制,并定位div
元素。
MarkerCounter.prototype.draw = function() {
var radius = this.radius;
var context = this.context;
context.clearRect(0,0,radius*2,radius*2);
context.fillStyle = "rgba(73,154,219,"+this.opacity+")";
context.beginPath();
context.arc(radius,radius, radius, 0, Math.PI*2, true);
context.closePath();
context.fill();
var projection = this.getProjection();
var point = projection.fromLatLngToDivPixel(this.opt.position);
this.div_.style.left = (point.x - radius) + 'px';
this.div_.style.top = (point.y - radius) + 'px';
};
当您运行应用程序时,您会发现现在我们的标记看起来像 Twitter 的标记,而来自某个位置的推文数量越多,我们 Twitter 鸟下的蛋就会越不透明。
工作原理...
第一步是交换默认标记的图形。由于我们正在扩展常规标记,因此我们拥有其所有默认功能和行为。其中一个功能是交换图标。为此,我们将我们的对象参数之一作为图标及其路径传递。
var marker = new TwitterMarker({
map: map,
position: latLng,
tweet: a,
title:a[0].text,
icon:"img/bird.png" });
您可能想知道这实际上是如何工作的,因为我们在代码中实际上没有对图标参数做任何事情。这很简单。如果您深入研究TwitterMaker
构造函数,您会发现以下行:
this.setValues(opt);
将setValues
方法传递给opt
对象是我们让标记继续并使用我们刚刚在构造函数中获得的信息渲染我们的标记的方式。所有常规标记中可以完成的事情在我们的标记中也可以完成。
在这个阶段,我们的 Twitter 鸟是我们标记的图形界面。不幸的是,这是我们可以自定义标记的最远的地方;接下来,我们需要添加另一个视觉层。由于我们希望创建一个视觉层,其行为就像标记一样(因为它将成为标记的一部分),我们需要为google.maps.OverlayView
对象创建一个子类。
与标记逻辑类似,当我们准备渲染元素时,我们希望调用setMap
方法(对于标记,这是一个不同的方法,但是相同的想法)。
function MarkerCounter(opt) {
this.radius = 15;
this.opacity = (opt.tweet.length) /100;
this.opt = opt;
this.setMap(opt.map);
}
MarkerCounter.prototype = new google.maps.OverlayView();
在我们的构造函数中,我们只存储非常基本的全局信息,例如我们的目标不透明度、半径和options
对象。我们可以在这里存储任何我们想要的信息。我们将需要的最重要的信息元素是位置(纬度和经度)。我们将发送该信息到我们的标记,并且它将在我们的opt
对象中。
google.maps.OverlayView
对象有一个onAdd
方法。它就像一个监听器,但另外,我们将覆盖此方法并在元素添加到地图中时添加我们的处理/准备工作。
MarkerCounter.prototype.onAdd = function() {
var div = document.createElement('div');
div.style.border = "none";
div.style.borderWidth = "0px";
div.style.position = "absolute";
this.canvas = document.createElement("CANVAS");
this.canvas.width = this.radius*2;
this.canvas.height = this.radius*2;
this.context = this.canvas.getContext("2d");
div.appendChild(this.canvas);
this.div_ = div;
var panes = this.getPanes();
panes.overlayLayer.appendChild(div);
}
这里的大部分逻辑应该看起来很熟悉。我们首先创建一个新的div
元素。我们设置它的 CSS 属性,使div
元素的位置绝对,这样我们就可以轻松地移动它。然后我们创建一个 canvas 元素,并将其宽度和高度设置为我们圆的半径的两倍。我们将 canvas 添加到我们的div
元素中。最后但并非最不重要的是,是时候将我们的div
元素添加到地图中了。我们将通过访问getPanes
方法来实现这一点。这个方法将返回这个元素可以包含的所有视觉层。在我们的情况下,我们将直接进入我们的覆盖层,并将我们的div
元素添加到其中。我们之所以在onAdd
方法中这样做,而不是在之前就这样做,是因为覆盖物不会被渲染,我们将无法访问前面代码中的最后两行。
就像我们重写了onAdd
方法一样,我们也对draw
方法做了同样的事情。这是我们最后的关键步骤。在大部分情况下,这个方法中的所有工作都会非常熟悉,因为在这本书中我们已经玩过很多 canvas。所以,让我们探索新的步骤,找到我们想要定位覆盖物的位置。
var projection = this.getProjection();
var point = projection.fromLatLngToDivPixel(this.opt.position);
this.div_.style.left = (point.x - radius) + 'px';
this.div_.style.top = (point.y - radius) + 'px';
在前面代码的第一行中,我们获取了投影。投影是我们覆盖物的相对视角。通过这个投影,我们可以提取出实际的像素点。我们调用projection.fromLatLngToDivPixel
方法,向它发送一个经纬度对象,并得到一个点(x
,y
值)。现在我们只需要根据这些信息更新我们的div
元素的样式,并将其定位到这个信息(不要忘记减去我们的半径大小,这样我们的元素就恰好位于被点击的实际点的中间)。
到目前为止,我们一直把我们的TwitterMarker
构造函数当作世界上总是有推文的地方,但现实是有时候可能什么都没有,而现在我们正在创建一个无法工作的可视化和一个无法可视化它的标记。让我们重写我们的行为,如果没有结果,就放上一个替代的标记,并跳过所有我们的自定义。
让我们整理一下。我们首先从showTweet
方法中删除我们原始的错误逻辑。相反,我们只会更新text
属性,而不会创建一个新的数组。
function showTweet(a,latLng){
var marker = new TwitterMarker({
map: map,
position: latLng,
tweet: a,
title:a.length? a[0].text : 'No tweet found in this area for this topic' ,
icon:"img/bird.png" });
}
如果你对三元运算符不熟悉,它是一种在代码中创建if...else
语句的非常简洁的方式。它的核心逻辑如下:
condition?true outcome:false outcome;
然后结果被发送回来,我们可以将其捕捉到我们的变量中,就像我们在这种情况下所做的那样。
接下来我们想要改变的是TwitterMarker
构造函数。
function TwitterMarker(opt){
if(!opt.tweet || !opt.tweet.length){
opt.icon = "img/x.png";
}else{
this.count = opt.tweet.length;
this.mc = new MarkerCounter(opt);
this.crnt = 0;
this.id = TwitterMarker.aMarkers.push(this);
this.aTweets = opt.tweet;
var strTweet = this.buildTwitterHTML(opt.tweet[0])
this.infoWindow = new google.maps.InfoWindow({
maxWidth:300,
content:strTweet
});
this.infoWindow.open(this.map,this);
google.maps.event.addListener(this, 'click', this.onMarkerClick);
}
this.setValues(opt);
}
这里的主要变化是,我们首先通过检查是否有任何推文来启动我们的应用程序。如果周围没有推文,我们将图标图形更新为一个新的X图标。如果我们有结果,一切都保持不变。我们将setValues
方法提取出来,以便在任何情况下都需要调用它。
完成了!我们完成了我们的社交地图。你可以用这个项目做更多的事情。一些例子可能是使更改搜索词更容易,并比较两个搜索结果(这可能非常有趣且容易)。我会很感兴趣地看到全世界提到 Flash 与 HTML5 的次数,所以如果你做到了,给我发封电子邮件。
最终项目:构建一个实时行程
尽管从我们之前的示例中自然的下一步是向我们已经在本章中构建的不断增长的社交地图添加额外的功能,但我们正在改变方向。
在我们的最终配方中,我们将构建一个交互式的 Google 地图,它将以我在写这本书时在南美洲的一位亲密朋友的旅行信息为动画。为了构建这个应用程序,我们将通过添加绘图和移动标记来为地图添加动画;我们将与旅行信息的外部源集成,并集成动画和文本片段来描述旅程。在下面的屏幕截图中,你可以看到一个非常小的纯路径快照:
准备就绪
在这个配方中,我们将使用许多元素,这些元素都是基于我们在所有章节中所做的工作。因此,如果你没有和我们一起经历这段旅程,那么就不容易立即开始。没有先决条件。我们将从头开始,但不会专注于我们已经学过的东西。
当用户在世界地图上“旅行”时,如果数据源中有用户的消息,地图将变暗,并在用户继续旅行之前显示消息:
如何做...
在这个配方中,我们将创建两个文件:一个 HTML 文件和一个 JavaScript 文件。让我们来看看它们,从 HTML 文件开始:
- 创建 HTML 文件。
<!DOCTYPE html>
<html>
<head>
<title>Google Maps Markers and Events</title>
<meta charset="utf-8" />
<meta name="viewport" content="initial-scale=1.0, user-scalable=no" />
<link href='http://fonts.googleapis.com/css?family=Yellowtail' rel='stylesheet' type='text/css'>
<style>
html { height: 100% }
body { height: 100%; margin: 0; padding: 0 }
#map { height: 100%; width:100%; position:absolute; top:0px; left:0px }
.overlay {
background: #000000 scroll;
height: 100%;
left: 0;
opacity: 0;
position: absolute;
top: 0;
width: 100%;
z-index: 50;
}
.overlayBox {
left: -9999em;
opacity: 0;
position: absolute;
z-index: 51;
text-align:center;
font-size:32px;
color:#ffffff;
font-family: 'Yellowtail', cursive;
}
</style>
<script src="img/jquery.min.js"></script>
<script src="img/js?key=AIzaSyBp8gVrtxUC2Ynjwqox7I0dxrqjtCYim-8&sensor=false"></script>
<script src="img/jsapi"></script>
<script src="img/10.05.travel.js"></script>
</head>
<body>
<div id="map"></div>
</body>
</html>
- 现在是时候转到 JavaScript 文件
10.05.travel.js
了。我们将通过初始化可视化库并存储全局地图变量来开始。
google.load('visualization', '1.0');
google.setOnLoadCallback(init);
- var map;
当触发
init
函数时,地图被加载,并触发加载 Google 电子表格,我们将在其中存储所有朋友的旅行信息。
function init() {
var BASE_CENTER = new google.maps.LatLng(48.516817734860105,13.005318750000015 );
map = new google.maps.Map(document.getElementById("map"),{
center: BASE_CENTER,
mapTypeId: google.maps.MapTypeId.SATELLITE,
disableDefaultUI: true,
});
var query = new google.visualization.Query(
'https://spreadsheets.google.com/tq?key=0Aldzs55s0XbDdERJVlYyWFJISFN3cjlqU1JnTGpOdHc');
query.send(onTripDataReady);
}
- 文档加载时,它将触发
onTripDataReady
监听器。当发生这种情况时,我们将想要创建一个新的GoogleMapTraveler
对象(一个用于管理我们的体验的自定义类)。
function onTripDataReady(response){
var gmt = new GoogleMapTraveler(response.g.D,map);
}
GoogleMapTraveler
对象的构造方法将准备我们的变量,创建一个新的Animator
对象、一个Traveler
对象和一个新的google.maps.Polyline
对象,并通过调用nextPathPoint
方法触发第一个旅行点的创建。
function GoogleMapTraveler(aData,map){
this.latLong; //will be used to store current location
this.zoomLevel; //to store current zoom level
this.currentIndex=0;
this.data = aData; //locations
this.map = map;
//this.setPosition(0,2);
this.animator = new Animator(30);
this.pathPoints = [this.getPosition(0,1)]; //start with two points at same place.
var lineSymbol = {
path: 'M 0,-1 0,1',
strokeOpacity: .6,
scale: 2
};
this.lines = new google.maps.Polyline({
path: this.pathPoints,
strokeOpacity: 0,
strokeColor: "#FF0000",
icons: [{
icon: lineSymbol,
offset: '0',
repeat: '20px'
}],
map: map
});
this.traveler = new Traveler(this.map,this.getPosition(0,1));
this.nextPathPoint(1);
}
getPosition
方法是一个非常聪明、小巧的方法,它使我们能够每次调用时创建一个新的google.maps.LatLng
对象,并根据点的平均值或一个项目创建一个点。
GoogleMapTraveler.prototype.getPosition = function (index,amount){
var lat=0;
var lng=0;
for(var i=0; i<amount; i++){
lat+= parseFloat(this.data[index+i].c[0].v);
lng+= parseFloat(this.data[index+i].c[1].v);
}
var ll=new google.maps.LatLng(
lat/amount,
lng/amount);
return ll;
}
- 我们希望能够设置我们旅行者的位置,因此我们还希望创建一个
setPosition
方法。
GoogleMapTraveler.prototype.setPosition = function(index,amount){
this.currentFocus = index;
var lat=0;
var lng=0;
for(var i=0; i<amount; i++){
lat+= parseFloat(this.data[index+i].c[0].v);
lng+= parseFloat(this.data[index+i].c[1].v);
}
var ll=new google.maps.LatLng(
lat/amount,
lng/amount);
if(this.data[index].c[2])this.map.setZoom(this.data[index].c[2].v);
this.map.setCenter(ll);
}
- 我们的应用程序的核心是能够自动从一步移动到下一步。这种逻辑是使用我们的
Animator
对象结合nextPathPoint
方法应用的:
GoogleMapTraveler.prototype.nextPathPoint = function(index){
this.setPosition(index-1,2);
this.pathPoints.push(this.getPosition(index-1,1)); //add last point again
var currentPoint = this.pathPoints[this.pathPoints.length-1];
var point = this.getPosition(index,1);
//console.log(index,currentPoint,point,this.getPosition(index,1));
this.animator.add(currentPoint,"Za",currentPoint.Za,point.Za,1);
this.animator.add(currentPoint,"Ya",currentPoint.Ya,point.Ya,1);
this.animator.add(this.traveler.ll,"Za",this.traveler.ll.Za,point.Za,2,0.75);
this.animator.add(this.traveler.ll,"Ya",this.traveler.ll.Ya,point.Ya,2,0.75);
this.animator.onUpdate = this.bind(this,this.renderLine);
this.animator.onComplete = this.bind(this,this.showOverlayCopy);//show copy after getting to destination
}
- 有两个回调通过我们的
Animator
对象触发(它们在前面的代码片段中突出显示)。现在是时候创建更新onUpdate
回调信息的逻辑了。让我们来看看renderLine
方法。
GoogleMapTraveler.prototype.renderLine = function(){
this.lines.setPath(this.pathPoints);
if(this.traveler.isReady)this.traveler.refreshPosition();
}
-
在下一步中,当动画完成时,它会触发我们的覆盖逻辑。覆盖逻辑非常简单;如果在 Google 文档的第五列中有文本,我们将使屏幕变暗并输入文本。如果没有文本,我们将跳过这一步,直接进入触发
hideOverlayCopy
方法的下一步(电子表格中的下一行)。 -
我们
GoogleMapTraveler
对象的先前方法是bind
方法。我们已经在第六章将静态事物变得生动的转向面向对象编程配方中创建了这个bind
方法;因此,我们不会进一步详细说明。
GoogleMapTraveler.prototype.bind = function(scope, fun){
return function () {
fun.apply(scope, arguments);
};
}
- 创建
Traveler
类。Traveler
类将基于本章中自定义标记的外观和感觉配方中的工作,只是这一次它将是一个动画标记。
function Traveler(map,ll) {
this.ll = ll;
this.radius = 15;
this.innerRadius = 10;
this.glowDirection = -1;
this.setMap(map);
this.isReady = false;
}
Traveler.prototype = new google.maps.OverlayView();
Traveler.prototype.onAdd = function() {
this.div = document.createElement("DIV");
this.canvasBG = document.createElement("CANVAS");
this.canvasBG.width = this.radius*2;
this.canvasBG.height = this.radius*2;
this.canvasFG = document.createElement("CANVAS");
this.canvasFG.width = this.radius*2;
this.canvasFG.height = this.radius*2;
this.div.style.border = "none";
this.div.style.borderWidth = "0px";
this.div.style.position = "absolute";
this.canvasBG.style.position = "absolute";
this.canvasFG.style.position = "absolute";
this.div.appendChild(this.canvasBG);
this.div.appendChild(this.canvasFG);
this.contextBG = this.canvasBG.getContext("2d");
this.contextFG = this.canvasFG.getContext("2d");
var panes = this.getPanes();
panes.overlayLayer.appendChild(this.div);
}
Traveler.prototype.draw = function() {
var radius = this.radius;
var context = this.contextBG;
context.fillStyle = "rgba(73,154,219,.4)";
context.beginPath();
context.arc(radius,radius, radius, 0, Math.PI*2, true);
context.closePath();
context.fill();
context = this.contextFG;
context.fillStyle = "rgb(73,154,219)";
context.beginPath();
context.arc(radius,radius, this.innerRadius, 0, Math.PI*2, true);
context.closePath();
context.fill();
var projection = this.getProjection();
this.updatePosition(this.ll);
this.canvasBG.style.opacity = 1;
this.glowUpdate(this);
setInterval(this.glowUpdate,100,this);
this.isReady = true;
};
Traveler.prototype.refreshPosition=function(){
this.updatePosition(this.ll);
}
Traveler.prototype.updatePosition=function(latlng){
var radius = this.radius;
var projection = this.getProjection();
var point = projection.fromLatLngToDivPixel(latlng);
this.div.style.left = (point.x - radius) + 'px';
this.div.style.top = (point.y - radius) + 'px';
}
Traveler.prototype.glowUpdate=function(scope){ //endless loop
scope.canvasBG.style.opacity = parseFloat(scope.canvasBG.style.opacity) + scope.glowDirection*.04;
if(scope.glowDirection==1 && scope.canvasBG.style.opacity>=1) scope.glowDirection=-1;
if(scope.glowDirection==-1 && scope.canvasBG.style.opacity<=0.1) scope.glowDirection=1;
}
- 我们将获取在第六章中创建的Animating independent layers食谱中创建的
Animator
类,并对其进行调整(代码片段中的更改已突出显示)。
function Animator(refreshRate){
this.onUpdate = function(){};
this.onComplete = function(){};
this.animQue = [];
this.refreshRate = refreshRate || 35; //if nothing set 35 FPS
this.interval = 0;
}
Animator.prototype.add = function(obj,property, from,to,time,delay){
obj[property] = from;
this.animQue.push({obj:obj,
p:property,
crt:from,
to:to,
stepSize: (to-from)/(time*1000/this.refreshRate),
delay:delay*1000 || 0});
if(!this.interval){ //only start interval if not running already
this.interval = setInterval(this._animate,this.refreshRate,this);
}
}
Animator.prototype._animate = function(scope){
var obj;
var data;
for(var i=0; i<scope.animQue.length; i++){
data = scope.animQue[i];
if(data.delay>0){
data.delay-=scope.refreshRate;
}else{
obj = data.obj;
if((data.stepSize>0 && data.crt<data.to) ||
(data.stepSize<0 && data.crt>data.to)){
data.crt = data.crt + data.stepSize;
obj[data.p] = data.crt;
}else{
obj[data.p] = data.to;
scope.animQue.splice(i,1);
--i;
}
}
}
scope.onUpdate();
if( scope.animQue.length==0){
clearInterval(scope.interval);
scope.interval = 0; //reset interval variable
scope.onComplete();
}
}
当您加载 HTML 文件时,您会发现一个全屏地图,它从电子表格中获取方向。它将以动画方式显示我朋友从以色列到南美洲再返回时所走过的路径。
它是如何工作的...
在这个例子中有许多组件,但我们主要关注我们在本书的其他部分中尚未涵盖的新步骤。
我们遇到的第一件新事物就在我们的 HTML 和 CSS 中:
<link href='http://fonts.googleapis.com/css?family=Yellowtail' rel='stylesheet' type='text/css'>
我们从 Google 字体库www.google.com/webfonts
中选择了一种字体,并将其集成到文本覆盖中。
.overlayBox {
...
font-family: 'Yellowtail', cursive;
}
现在是时候进入我们的 JavaScript 文件了,我们首先加载 Google 可视化库。这是我们在第八章中使用的相同库,与 Google 图表一起玩。加载完成后,将触发init
函数。init
函数启动我们的地图,并开始加载电子表格。
在第八章的将数据源更改为 Google 电子表格食谱中,与 Google 图表一起玩,我们首次使用了 Google 电子表格。在那里,您学会了准备和添加 Google 图表到 Google 可视化中所涉及的所有步骤。在我们的情况下,我们创建了一个包含我朋友走过的所有地区的线路的图表。
在这种情况下的例外是,我们不想将我们的 URL 提供给 Google 图表,而是想直接使用它。为此,我们将使用 Google 的 API 接口之一,google.visualization.Query
对象:
var query = new google.visualization.Query(
'https://spreadsheets.google.com/tq?key=0Aldzs55s0XbDdERJVlYyWFJISFN3cjlqU1JnTGpOdHc');
query.send(onTripDataReady);
下一步是创建我们的GoogleMapTraveler
对象。Google 地图旅行者是我们与 Google 地图一起工作的一种新方式。它不扩展 Google 地图的任何内置功能,而是我们过去创建的所有其他想法的中心。它被用作标记管理中心,称为 Traveler,我们将很快创建的google.maps.Polyline
对象,它使我们能够在地图上绘制线条。
我们不想让新添加到 Google 地图中的线条呈现出非常静态的外观,而是想为其创建一个揭示效果。为了实现这一点,我们需要一种方法,每隔几毫秒更新折线以创建动画。一开始,我就知道起点和终点,因为我从之前创建的 Google 电子表格中获取了这些信息。
这个想法非常简单,尽管在一个非常复杂的生态系统中。这个想法是创建一个数组,用于存储所有的纬度/经度点。然后,每当我们想要更新屏幕时,将其提供给this.line
对象。
这个应用程序的逻辑核心存储在这行代码中:
this.nextPathPoint(1);
它将开始一个递归的旅程,遍历图表中的所有点。
还有更多...
让我们更深入地了解GoogleMapTraveler.prototype.nextPathPoint
方法背后的逻辑。在这个函数中,我们首先要做的事情是设置地图视图。
this.setPosition(index-1,2);
setPosition
方法执行了一些与根据发送的当前索引中的数据重新定位地图和缩放级别相关的操作。它比这更智能,因为它接受第二个参数,使其能够对两个点进行平均。当一个人在两点之间旅行时,最好是我们的地图位于两点的中心。通过将2
作为第二个参数发送进去来实现。setPosition
方法的内部逻辑很简单。它将循环遍历需要的项目,以平均出正确的位置。
接下来,我们向我们的this.pathPoints
数组添加一个新点。我们首先复制已经在数组中的相同点,因为我们希望我们的新第二点从起点开始。这样,我们可以每次更新数组中的最后一个值,直到它达到终点(真正的下一个点)。
this.pathPoints.push(this.getPosition(index-1,1)); //add last point again
我们创建了一些辅助变量。一个将指向我们刚刚创建并推送到我们的pathPoints
数组中的新对象。第二个是我们希望在动画结束时达到的点。
var currentPoint = this.pathPoints[this.pathPoints.length-1];
var point = this.getPosition(index,1);
注意
第一个变量不是一个新对象,而是对最后创建的点的引用,第二行是一个新对象。
我们的下一步将是开始并动画化我们的currentPoint
的值,直到它达到point
对象中的值,并更新我们的旅行者纬度/经度信息,直到它也到达目的地。我们给第二个动画延迟 0.75 秒,以使事情更有趣。
this.animator.add(currentPoint,"Za",currentPoint.Za,point.Za,1);
this.animator.add(currentPoint,"Ya",currentPoint.Ya,point.Ya,1);
this.animator.add(this.traveler.ll,"Za",this.traveler.ll.Za,point.Za,2,0.75);
this.animator.add(this.traveler.ll,"Ya",this.traveler.ll.Ya,point.Ya,2,0.75);
在结束这个方法之前,我们想实际动画化我们的线。现在,我们正在动画化两个不可视的对象。为了开始动画化我们的可视元素,我们将监听更新,直到我们完成动画。
this.animator.onUpdate = this.bind(this,this.renderLine);
this.animator.onComplete = this.bind(this,this.showOverlayCopy);//show copy after getting to destination
每次动画发生时,我们在renderLine
方法中更新我们的可视元素的值。
为了避免运行时错误,我们为旅行者标记添加了一个isReady
布尔值,以指示我们的元素何时准备好绘制。
this.lines.setPath(this.pathPoints);
if(this.traveler.isReady)this.traveler.refreshPosition();
当动画完成时,我们转到showOverlayCopy
方法,在那里我们接管屏幕并以与之前相同的策略动画复制。这一次,当我们完成这个阶段时,我们将再次触发我们的初始函数,并以更新后的索引重新开始整个循环。
GoogleMapTraveler.prototype.hideOverlayCopy = function(){
//update index now that we are done with initial element
this.currentIndex++;
...
//as long as the slide is not over go to the next.
if(this.data.length>this.currentIndex+1)this.nextPathPoint(this.currentIndex+1);
}
这涵盖了我们构建的核心。现在是时候简要谈谈另外两个类,这些类将帮助创建这个应用程序。
理解旅行者标记
我们不会深入研究这个类,因为在很大程度上,它是基于我们在上一个配方中所做的工作,“自定义标记的外观和感觉”。最大的区别是,我们在我们的元素中添加了内部动画和一个updatePosition
方法,使我们能够在需要移动时移动我们的标记。
Traveler.prototype.updatePosition=function(latlng){
var radius = this.radius;
var projection = this.getProjection();
var point = projection.fromLatLngToDivPixel(latlng);
this.div.style.left = (point.x - radius) + 'px';
this.div.style.top = (point.y - radius) + 'px';
}
这个方法得到一个纬度和经度,并更新标记的位置。
由于我们正在动画化这个对象的实际ll
对象在主类中,我们添加了第二个方法refreshPosition
,每次更新动画时都会调用它。
Traveler.prototype.refreshPosition=function(){
this.updatePosition(this.ll);
}
在这个课程中还有更多可以探索和发现的东西,但我会留给你一些乐趣。
更新动画对象
我们对我们的Animator
类进行了两个重大更新,这个类最初是在第六章的“独立层动画”配方中创建的,“让静态事物活起来”。第一个变化是集成回调方法。回调的概念与事件非常相似。回调使我们能够在发生某事时调用一个函数。这种工作方式使我们一次只能有一个监听器。为此,我们首先创建了以下两个变量,它们是我们的回调函数:
function Animator(refreshRate){
this.onUpdate = function(){};
this.onComplete = function(){};
然后我们在Animator
类中的相关位置(在更新或完成时)触发这两个函数。在我们的GoogleMapTraveler
对象中,我们用GoogleMapTraveler
对象内部的函数覆盖了默认函数。
我们对Animator
对象的第二个也是最后一个重大更新是,我们添加了更智能、更详细的逻辑,使我们的动画师能够动画化正负区域。我们的原始动画没有考虑到动画化纬度/经度值,因此我们调整了核心动画逻辑。
这涵盖了我们在这个食谱中探索的一些重要新事物。这个食谱中还充满了我们在各章中学到的许多其他小东西。我真诚希望你喜欢和我一起走过这段旅程,因为这是我们书的结尾。请随时与我分享你的工作和见解。你可以在02geek.com
找到我,我的电子邮件是<ben@02geek.com>
。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· CSnakes vs Python.NET:高效嵌入与灵活互通的跨语言方案对比
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· Plotly.NET 一个为 .NET 打造的强大开源交互式图表库
· 上周热点回顾(2.17-2.23)