第21章 多媒体和图形编程
本章将介绍如何使用JavaScript来操作图片、控制音频和视频流以及画图。21.1节会介绍如何用传统的JavaScript技术实现诸如图片翻转(鼠标指针移动到一张静态图片上切换成另外一张图片)这样的视觉效果。紧接着,21.2节会介绍HTML5的<audio>和<video>元素以及它们的JavaScript API。
在前两节对图片、音频和视频的介绍之后,接下来会介绍两项非常强大的用于客户端绘图的技术。能够在浏览器中动态生成复杂图形是非常重要的,因为:
·用于在客户端生成图形的代码大小要比图片本身小很多,这样可以减少部分带宽。
·通过一些实时数据来动态生成图形,需要消耗大量的CPU周期。而如果把这个任务放到客户端做,就可以有效地减轻服务器的负担,某种程度上也是节约了硬件开销。
·在客户端生成图形也是符合现代Web应用的架构:服务器提供数据,然后客户端负责展现这些数据。
21.3 节会介绍可伸缩的矢量图形(Scalable Vector Graphics,SVG)。SVG是一种基于XML的并且用于描述图形的语言,SVG图形可以通过JavaScript和DOM来创建和操控。最后,21.4节会介绍HTML5的<canvas>元素及其用于客户端画图的、功能齐全的JavaScript API。<canvas>元素是一项革命性的技术,本章会对它做详细的介绍。
21.1 脚本化图片
Web页面使用HTML的<img>元素来嵌入图片。和所有HTML元素一样,<img>元素也是可以通过脚本来操控的:设置元素的s rc属性,将其指向一个新的URL会导致浏览器载入(如果需要的话)并展示一张新的图片。(还可以通过脚本来控制图片的宽度和高度,这会使得浏览器缩小和放大图片,但是这种技术这里不会做介绍。)
在HTML文档中动态替换图片,这样一种能力,使得许多特效成为可能。其中最常用的特效就是图片翻转,图片会随着鼠标指针划过进行替换。如果图片本身包含超链接,并且可单击,那么图片翻转这种特效是一种引导用户单击图片非常有效的方式。(实现同样的效果也可以不使用脚本,而是使用CSS中的:hover伪类,替换元素的背景图片来实现。)如下的HTML代码段是一个非常简单的例子:它创建一张图片,并在鼠标指针经过的时候改变该图片:
<img src="images/help.gif"
onmouseover="this.src='images/help_rollover.gif'"
onmouseout="this.src='images/help.gif'">
当鼠标指针经过或者离开<img>元素时候,事件处理程序会重新设置其src属性。图片翻转和鼠标单击紧密联系在一起,因此<img>元素应当包含在一个<a>元素中或者指定一个onclick事件处理程序。
为了有用起见,像图片翻转这样的效果需要较高响应度。这也意味着需要想办法来确保一些必要的图片要预提取,让浏览器缓存起来。客户端JavaScript定义了一个专用的API来达到这一目的:为了强制让图片缓存起来,首先利用Image()构造函数来创建一个屏幕外图片对象,之后,将该对象的src属性设置成期望的URL。由于图片元素并没有添加到文档中,因此,它是不可见的,但是浏览器还是会加载图片并将其缓存起来。这样一来,之后当设置成同样的URL来显示该屏幕内图片的时候,它就能很快从浏览器缓存中加载,而不需要再通过网络加载。
前面展示的图片翻转的代码片段并没有预提取它使用的翻转图片,这样,当用户第一次将鼠标指针移到图片上的时候会明显感到翻转效果有延时。要解决这个问题,将代码修改成如下形式:
<script>(new Image()).src="images/help_rollover.gif";</script>
<img src="images/help.gif"
onmouseover="this.src='images/help_rollover.gif'"
onmouseout="this.src='images/help.gif'">
优雅的图片翻转实现方式
刚刚展示的代码需要一个<script>元素和两个JavaScript事件处理程序的属性来实现一个简单的图片翻转效果。这个例子的代码非常不优雅:大量的JavaScript和HTML代码混在一起。例21-1展示了一种更为优雅的实现方式,这种方式允许在任意的<img>元素上,只要简单地指定了data-rollover属性(参见15.4.3节),就会创建一个图片翻转效果。要注意的是,该例使用了例13-5中介绍的onLoad()函数。同时它还用到了document.images[]数组(参见15.2.3节)从文档中查找所有的<img>元素。
例21-1:优雅的图片翻转实现方式
/**
*rollover.js:优雅的图片翻转实现方式
*
*要创建图片翻转效果,将此模块引入到HTML文件中
*然后在任意<img>元素上使用data-rollover属性来指定翻转图片的URL即可
*如下所示:
*
*<img src="normal_image.png"data-rollover="rollover_image.png">
*
*要注意的是,此模块依赖于onLoad.js
*/
onLoad(function(){//所有处理逻辑都在一个匿名函数中:不定义任何符号
//遍历所有的图片,查找data-rollover属性
for(var i=0;i<document.images.length;i++){
var img=document.images[i];
var rollover=img.getAttribute("data-rollover");
if(!rollover)continue;//跳过没有data-rollover属性的图片
//确保将翻转的图片缓存起来
(new Image()).src=rollover;//定义一个属性来标识默认的图片URL
img.setAttribute("data-rollout",img.src);//注册事件处理函数来创建翻转效果
img.onmouseover=function(){
this.src=this.getAttribute("data-rollover");
};
img.onmouseout=function(){
this.src=this.getAttribute("data-rollout");
};
}
});
21.2 脚本化音频和视频
从理论上说,HTML5引入的<audio>和<video>元素,使用起来和<img>元素一样简单。对于支持HTML5的浏览器,不再需要使用插件(像Flash)来在HTML文档中嵌入音频和视频:
<audio src="background_music.mp3"/>
<video src="news.mov"width=320 height=240/>
实际上,使用这些元素的时候要更加巧妙。由于各家浏览器制造商未能在对标准音频和视频编解码器支持上达成一致,因此,通常都需要使用<source>元素来为指定不同格式的媒体源:
<audio id="music">
<source src="music.mp3"type="audio/mpeg">
<source src="music.ogg"type='audio/ogg;codec="vorbis"'>
</audio>
要注意的是,<source>元素没有任何内容:没有闭合的</source>标签,也不需要使用“/>”来结束它们。
支持<audio>和<video>元素的浏览器不会渲染这些元素的内容。而不支持它们的浏览器则会将它们的内容都渲染出来,因此,可以在这些元素中放置后备内容(比如,一个用于调用Flash插件的<object>元素):
<video id="news"width=640 height=480 controls preload>
<!—Firefox和Chrome支持的WebM格式—>
<source src="news.webm"type='video/webm;codecs="vp8,vorbis"'>
<!—IE和Safari支持的H.264格式—>
<source src="news.mp4"type='video/mp4;codecs="avc1.42E01E,mp4a.40.2"'>
<!—Flash插件作为后备方案—>
<object width=640 height=480 type="application/x-shockwave-flash"
data="flash_movie_player.swf">
<!—这里的参数元素用于配置Flash视频播放器—>
<!—文本是最终的后备内容—>
<div>video element not supported and Flash plugin not installed.</div>
</object>
</video>
<audio>和<video>元素支持一个controls属性。如果设置了该属性(或者对应的JavaScript属性设置为true),它们将会显示一系列播放控件,包括播放、暂停按钮、音量控制等。除此之外,<audio>和<video>元素还提供了API能让脚本控制媒体,使用该API可以实现在Web应用中添加简单的声音效果或者创建自定义音频和视频控制面板。尽管,音频和视频控制面板在外观上有很大差别,但是两个元素基本共享相同的API(唯一不同的是,<video>元素还有width和height属性),本节后面要介绍的很多内容对两个元素几乎都有效。
Audio()构造函数
在不设置controls属性的情况下,<audio>元素没有任何视觉外观。正如可以使用Image()构造函数来创建一张屏幕外图片那样,HTML5中的媒体API同样也允许使用Audio()构造函数,并将媒体源URL作为参数,来创建一个屏幕外音频元素:
new Audio("chime.wav").play();//载入并播放声音效果
Audio()构造函数的返回值和通过从文档中查询<audio>元素或者使用document.createElement("audio")来创建一个新的元素获得的都是同一类对象。这里要注意的是,Audio()是音频元素特有的API,换句话说,视频元素是没有类似Video()这样的构造函数的。
尽管对于多种不同格式的文件要分别定义媒体比较繁琐,但是,能够不借助插件在浏览器中原生播放音频和视频是HTML5中非常强大的新特性。要注意的是,对于媒体编解码器的问题以及浏览器对其兼容性的问题并不在本书讨论的范畴。接下来会集中讨论如何利用JavaScript API来操控音频和视频流。
21.2.1 类型选择和加载
想要测试一个媒体元素能否播放指定类型的媒体文件,可以调用canPlayType()方法并将媒体的MIME类型(有时需要包含codec参数)传递进去。如果它不能播放该类型的媒体文件,该方法会返回一个空的字符串(一个假值);反之,它会返回一个字符串:"maybe"或者"probably"。之所以返回"probably"这样不确定的结果,是因为音频和视频编解码器本身就非常复杂,在没有真正下载并尝试播放指定类型的媒体前很难确定是否真的可以支持播放此类型文件:
var a=new Audio();
if(a.canPlayType("audio/wav")){
a.src="soundeffect.wav";
a.play();
}
当设置媒体元素的src属性的时候,加载媒体的过程就开始了(除非将preload设置成"auto",否则,只会加载少量内容,因此该过程不会持续很长时间)。当设置src属性的时候,如果有其他的媒体文件正在加载或者播放,则会中止它们的加载或者播放过程。如果通过在媒体元素中添加<source>元素而不是设置src属性的方式指定媒体源,媒体元素无法知道是否已经将一系列<source>元素都添加完毕了,因此它也不会开始选择并加载<source>元素指定的媒体源文件,除非显式地调用load()方法。
21.2.2 控制媒体播放
<audio>和<video>元素最重要的方法是play()和pause()方法,它们用来控制媒体开始和暂停媒体的播放:
//文档载入完成后,开始播放背景音乐
window.addEventListener("load",function(){document.getElementById("music").play();
},false);
除了开始和暂停播放音频和视频,还可以通过设置currentTime属性来进行定点播放。该属性指定了播放器应该跳过播放的时间(单位为秒),可以在媒体播放或者暂停的时候设置该属性。(initialTime和duration属性确定了currentTime的有效取值范围;后面会对这些属性做详细介绍。)
volume属性表示播放音量,介于0(静音)~1(最大音量)之间。将muted属性设置为true则会进入静音模式,设置为false则会恢复之前指定的音量继续播放。
playbackRate属性用于指定媒体播放的速度。该属性值为1.0表示正常速度,大于1则表示“快进”,0~1之间的值则表示“慢放”。负值则表示回放,但是直到撰写本书时,浏览器还未支持该特性。<audio>和<video>元素还有一个defaultPlaybackRate属性。不管是否调用play()方法来播放媒体,playbackRate属性默认值都会被设置成defaultPlaybackRate的值。
要注意的是,currentTime、volume、muted以及playbackRate属性并不只是用于控制媒体播放。如果一个<audio>或者<video>元素有controls属性,它就会在播放器上显示控件,让用户控制媒体的播放。不仅如此,脚本也可以通过查询诸如muted和currentTime这样的属性来得知当前媒体的播放情况。
controls、loop、preload以及autoplay这样的HTML属性不仅影响音频和视频的播放,而且还可以作为JavaScript属性来设置和查询。controls属性指定是否在浏览器中显示播放控件。设置该属性值为true表示显示控件,反之表示隐藏控件。loop属性是布尔类型,它指定媒体是否需要循环播放,true表示需要循环播放,false则表示播放到最后就停止。preload属性指定在用户开始播放媒体前,是否或者多少媒体内容需要预加载。该属性值为"none"则表示不需要预加载数据。为"metadata"则表示诸如时长、比特率、帧大小这样的元数据而不是媒体内容需要加载。其实,在不设置preload属性的情况下,浏览器默认也会加载这些元数据的。preload属性值如果为"auto"则表示浏览器应当预加载它认为适量的媒体内容。最后,autoplay属性指定当已经缓存足够多的媒体内容时是否需要自动开始播放。将该属性设置为"true"就等于是告诉浏览器需要预加载媒体内容。
21.2.3 查询媒体状态
<audio>和<video>元素有一些只读属性,描述媒体以及播放器当前的状态:如果播放器暂停,那么paused属性的值就为"true"。如果播放器正在跳到一个新的播放点,那么seeking属性的值就为"true"。如果播放器播放完媒体并且停下来,那么ended属性的值就为"true"(如果设置loop属性值为true,那么ended属性值永远不为"true"。)
duration属性指定了媒体的时长,单位是秒。如果在媒体元数据还未载入前查询该属性,它会返回NaN。对于像Internet广播这样有无限时长的流媒体而言,该属性会返回Infinity。
initialTime属性指定了媒体的开始时间,单位也是秒。对于固定时长的媒体剪辑而言,该属性值通常是0。而对于流媒体而言,该属性表示已经缓存的数据的最早时间以及能够回退到的最早时间。当设置currentTime属性时,其值不能小于initialTime的值。
其他三个属性分别指定包含媒体时间轴、播放和缓冲状态的较细粒度视图。played属性返回已经播放的时间段。buffered属性返回当前已经缓冲的时间段,seekable属性则返回当前播放器需要跳到的时间段。(可以使用这些属性来实现一个进度条,显示currentTime、duration以及媒体的播放量和缓冲量。)
played、buffered和seekable都是TimeRanges对象。每个对象都有一个length属性以及start()和end()方法,前者表示当前的一个时间段,后者分别返回当前时间段的起始时间点和结束时间点(单位都是秒)。对于一段常见的连续时间段来说,一般使用start(0)和end(0)。例如,假设媒体文件从开始缓存起中间没有定点播放发生(跳过一段播放),可以使用如下代码来确定当前缓存内容的百分比:
var percent_loaded=Math.floor(song.buffered.end(0)/song.duration*100);
最后,还有另外三个属性:readyState、networkState和error,它们包含<audio>和<video>元素更加底层的一些状态细节。每个属性都是数字类型的,而且为每个有效值都定义了对应的常量。不过要注意的是,这些常量是定义在媒体对象(或者错误对象)上的。可以按照如下方式来使用一个属性:
if(song.readyState===song.HAVE_ENOUGH_DATA)song.play();
readyState属性指定当前已经加载了多少媒体内容,因此同时也暗示着是否已经准备好可以播放了。如下表格展示了该属性的取值以及对应的意义:
NetworkState属性指定媒体元素是否使用网络或者为什么媒体文件不使用网络:
当在加载媒体或者播放媒体过程中发生错误时,浏览器就会设置<audio>或者<video>元素的error属性。在没有错误发生的情况下,error属性值为null。反之,error的属性值是一个对象,包含了描述错误的数值code属性。同时,error对象也定义了一些描述可能的错误代码的常量:
可以以如下方式使用error属性:
if(song.error.code==song.error.MEDIA_ERR_DECODE)
alert("Can't play song:corrupt audio data.");
21.2.4 媒体相关事件
<audio>和<video>都是相对比较复杂的元素——它们必须不仅要对用户与播放控件的交互作出响应,还要对网络活动做出响应,甚至在播放的时候,对播放时间做出响应。与此同时,正如之前介绍过的,它们还有一些属性来表示它们当前的状态。和大多数HTML元素一样,<audio>和<video>元素在它们状态发生改变的时候,都会触发一些相应的事件。由于它们的状态比较复杂,因此触发的事件种类也比较多。
下表根据它们触发的先后顺序,总结了22个媒体相关事件。这些事件不能通过属性来注册事件,只能通过<audio>和<video>元素的addEventListener()方法来注册处理程序函数。
21.3 SVG:可伸缩的矢量图形
SVG是一种用于描述图形的XML语法。顾名思义,其中"vector"一词表示它完全不同于光栅图像格式,诸如GIF、JPEG和PNG(用像素值来描绘的矩阵)。一个"SVG"图形是对画该图形时的必要路径的一种精准、分辨率无关(因此是可伸缩的)的描述。一个简单的SVG文件如下所示:
<!--SVG图形一开始声明命名空间-->
<svg xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1000 1000"><!--图形的坐标系-->
<defs><!--设置后面要用到的一些定义-->
<linearGradient id="fade"><!--将一种渐变色命名为"fade"-->
<stop offset="0%"stop-color="#008"/><!--深蓝-->
<stop offset="100%"stop-color="#ccf"/><!--渐变到浅蓝-->
</linearGradient>
</defs>
<!--画一个具有宽的黑色边框并且渐变色为填充色的矩形-->
<rect x="100"y="200"width="800"height="600"
stroke="black"stroke-width="25"fill="url(#fade)"/>
</svg>
图21-1展示了上述代码以图形方式渲染时SVG文件的样子。
SVG这种语法比较庞大并且有一定的复杂度。它不仅可以用于简单的基本图形的绘制以外,还支持任意曲线、文本以及动画的绘制。SVG图形甚至还能整合JavaScript脚本和CSS样式表来添加行为和展示信息。本节将介绍客户端JavaScript代码(内嵌在HTML中,而不是SVG中)如何利用SVG动态绘制图形。会有一些SVG例子展示,但是只会牵涉SVG的基本知识。要了解关于SVG的详细内容,可以参阅SVG的标准文档,该文档比较全面地介绍了SVG。这份文档由W3C负责维护,地址在:http://www.w3.org/TR/SVG/。要注意的是,文档包含了完整的用于SVG文档的文档对象模型。但是本节使用标准的XML DOM而非SVG DOM绘制SVG图形。
截至撰写本书时,除了IE以外的所有主流浏览器都支持SVG(IE9也将支持)。在最新的浏览器中,可以使用普通的<img>元素来展示SVG图片。而相对早期的浏览器(比如:Firefox3.6)还不支持SVG,需要使用<object>元素:
<object data="sample.svg"type="image/svg+xml"width="100"height="100"/>
图 21-1 一个简单的SVG图形
当使用<img>或者<object>元素展示SVG图形的时候,SVG就变成了另外一种图片格式了,这种方式对于JavaScript程序员来说是不友好的。更好的方式是直接将SVG图片嵌入到HTML文档中,这样这些图片就可以通过脚本的方式来控制。由于SVG就是一种XML语法,因此可以将它以如下的方式嵌入到XHTML文档中:
<?xml version="1.0"?>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:svg="http://www.w3.org/2000/svg">
<!—声明HTML作为默认的命名空间,以"svg:"前缀的为SVG的命名空间—>
<body>
This is a red square:<svg:svg width="10"height="10">
<svg:rect x="0"y="0"width="10"height="10"fill="red"/>
</svg:svg>
This is a blue circle:<svg:svg width="10"height="10">
<svg:circle cx="5"cy="5"r="5"fill="blue"/>
</svg:svg>
</body>
</html>
这种展示SVG图形的技术除了IE以外的当前浏览器都支持。图21-2展示了上述XHTML文档经过Firefox渲染之后的图形。
图 21-2 内嵌在XHTML文档中的SVG图形
HTML5将XML和HTML的区别进一步缩小,允许SVG(和MathML)标记直接在HTML文件中使用,不需要命名空间的声明或者标签前缀:
<!DOCTYPE html>
<html>
<body>
This is a red square:<svg width="10"height="10">
<rect x="0"y="0"width="10"height="10"fill="red"/>
</svg>
This is a blue circle:<svg width="10"height="10">
<circle cx="5"cy="5"r="5"fill="blue"/>
</svg>
</body>
</html>
截至撰写本书时,只有最新的浏览器才支持像如上代码那样在HTML中直接内嵌SVG。
SVG就是一种XML语法,因此画SVG图形其实就相当于是在使用DOM创建相应的XML元素。例21-2是一个pieChart()函数,该函数用来创建SVG元素,最终渲染成图21-3所示的饼状图。
图 21-3 用JavaScript绘制而成的SVG饼状图
例21-2:使用JavaScript和SVG来绘制饼状图
/**
*创建一个<svg>元素,并在其中绘制一个饼状图
*参数:
*data:用于绘制的数字类型的数组,数组每一项都表示饼状图的一个楔
*width,height:SVG图形的大小,单位为像素
*cx,cy,r:饼状图的圆心以及半径
*colors:一个包含HTML颜色信息的数组,每种颜色代表饼状图每个楔的颜色
*labels:一个标签数组,该信息说明饼状图中每个楔代表的含义
*lx,ly:饼状图的左上角
*返回:
*一个保存饼状图的<svg>元素
*调用者必须将返回的元素插入到文档中
*/
function pieChart(data,width,height,cx,cy,r,colors,labels,lx,ly){//这个是表示svg元素的XML命名空间
var svgns="http://www.w3.org/2000/svg";
//创建一个<svg>元素,同时指定像素大小和用户坐标
var chart=document.createElementNS(svgns,"svg:svg");
chart.setAttribute("width",width);
chart.setAttribute("height",height);
chart.setAttribute("viewBox","0 0"+width+""+height);//累加data的值,以便于知道饼状图的大小
var total=0;
for(var i=0;i<data.length;i++)total+=data[i];//现在计算出饼状图每个分片的大小,其中角度以弧度制计算
var angles=[]
for(var i=0;i<data.length;i++)angles[i]=data[i]/total*Math.PI*2;//遍历饼状图的每个分片
startangle=0;
for(var i=0;i<data.length;i++){//这里表示楔的结束位置
var endangle=startangle+angles[i];//计算出楔和圆相交的两个点
//这些计算公式都是以12点钟方向为0<sup class="calibre5">o</sup>
//顺时针方向角度递增
var x1=cx+r*Math.sin(startangle);
var y1=cy-r*Math.cos(startangle);
var x2=cx+r*Math.sin(endangle);
var y2=cy-r*Math.cos(endangle);//这个标记表示角度大于半圆
//此标记在绘制SVG弧形组件的时候需要
var big=0;
if(endangle-startangle>Math.PI)big=1;//使用<svg:path>元素来描述楔
//要注意的是,使用createElementNS()来创建该元素
var path=document.createElementNS(svgns,"path");//下面的字符串包含路径的详细信息
var d="M"+cx+","+cy+//从圆心开始
"L"+x1+","+y1+//画一条到(x1,y1)的线段
"A"+r+","+r+//再画一条半径为r的弧
"0"+big+"1"+//弧的详细信息
x2+","+y2+//弧到(x2,y2)结束
"Z";//当前路径到(cx,cy)结束
//设置<svg:path>元素的属性
path.setAttribute("d",d);//设置路径
path.setAttribute("fill",colors[i]);//设置楔的颜色
path.setAttribute("stroke","black");//楔的外边框为黑色
path.setAttribute("stroke-width","2");//两个单位宽
chart.appendChild(path);//将楔加入到饼状图中
//当前楔的结束就是下一个楔的开始
startangle=endangle;//现在绘制一些相应的小方块来表示图例
var icon=document.createElementNS(svgns,"rect");
icon.setAttribute("x",lx);//定位小方块
icon.setAttribute("y",ly+30*i);
icon.setAttribute("width",20);//设置小方块的大小
icon.setAttribute("height",20);
icon.setAttribute("fill",colors[i]);//填充小方块的颜色和对应的楔的颜色相同
icon.setAttribute("stroke","black");//子外边框颜色也相同
icon.setAttribute("stroke-width","2");
chart.appendChild(icon);//添加到饼状图中
//在小方块的右边添加标签
var label=document.createElementNS(svgns,"text");
label.setAttribute("x",lx+30);//定位标签文本
label.setAttribute("y",ly+30*i+18);//文本样式属性还可以通过CSS来设置
label.setAttribute("font-family","sans-serif");
label.setAttribute("font-size","16");//在<svg:text>元素中添加一个DOM文本节点
label.appendChild(document.createTextNode(labels[i]));
chart.appendChild(label);//将文本添加到饼状图中
}
return chart;
}
例21-2中的代码相对比较易懂。其中有个小的数学变换将数据转换成可绘制的饼楔角。但是,这些例子都是使用DOM代码来创建SVG元素并设置元素属性。为了在不完全支持HTML5的浏览器下也能正常工作,该例子使用XML语法来处理SVG,使用SVG命名空间以及createElementNS()这样的DOM方法而不是createElement()。
上述例子中最难懂的部分就是精确绘制饼楔的部分。每一个饼楔都使用<svg:path>元素来表示。该SVG元素可以描述由直线和曲线组成的任意形状。具体形状的描述是通过设置<svg:path>元素的d属性来实现的。该属性值使用简洁的语法:通过字母和数字来指定坐标、角度和其他的值。比如:字母M表示"move to",同时指定要移动到的坐标(X、Y)。字母L则表示"line to",并绘制一条从当前位置到其后指定坐标的直线。上述例子还使用了字母A来绘制弧形。该字母之后需要指定7个数字值来描述要绘制的弧形。与之相关的详细信息在这里不重要,想要了解可以去参阅它的标准文档,http://www.w3.org/TR/SVG/。
要注意的是,pieChart()返回一个包含饼状图描述信息的<svg>元素,但是,它并没有将该元素插入到文档中。因此,调用者需要手动将其插入到文档中。使用如下代码可以创建出如图21-3所示的饼状图:
<html>
<head>
<script src="PieChart.js"></script>
</head>
<body onload="document.body.appendChild(
pieChart([12,23,34,45],640,400,200,200,150,
['red','blue','yellow','green'],
['North','South','East','West'],400,100));
">
</body>
</html>
例21-3是另外一个用脚本绘制SVG图形的例子:它使用SVG来绘制一个模拟时钟(参见图21-4)。该例以一张内嵌在HTML页面中的静态SVG时钟图片开始,而不是从零开始动态构造一棵SVG元素树来实现一个动态的时钟。那张静态的时钟图片包含两个SVG<line>元素来分别表示时针和分针。两条线都竖直显示,表示时间12:00。随后,通过JavaScript设置每个<line>元素的transform属性,让它们旋转一定的角度以显示正确的时间,以此来实现一个动态时钟。
图 21-4 一个SVG时钟
要注意的是,例21-3直接将SVG标记内嵌到HTML5文件中,而没有在XHTML文件中使用XML命名空间。这就意味着,它只有在支持直接内嵌SVG的浏览器中才能正常工作。然而,通过将HTML文件转换成XHTML,同样的技术也能够在早期支持SVG的浏览器中生效。
例21-3:通过控制SVG图片来显示时间
<!DOCTYPE HTML>
<html>
<head>
<title>Analog Clock</title>
<script>
function updateTime(){//更新SVG时钟来显示当前时间
var now=new Date();//当前时间
var min=now.getMinutes();//分钟
var hour=(now.getHours()%12)+min/60;//转换成可以在时钟上表示的时间
var minangle=min*6;//每6<sup class="calibre5">o</sup>表示一分钟
var hourangle=hour*30;//每30<sup class="calibre5">o</sup>表示一个小时
//获取表示时钟时针和分针的SVG元素
var minhand=document.getElementById("minutehand");
var hourhand=document.getElementById("hourhand");//设置这些元素的SVG属性,将它们移动到钟面上
minhand.setAttribute("transform","rotate("+minangle+",50,50)");
hourhand.setAttribute("transform","rotate("+hourangle+",50,50)");//每一分钟更新下时钟显示时间
setTimeout(updateTime,60000);
}
</script>
<style>/*下面定义的所有CSS样式都会作用在SVG元素上*/
#clock{/*用于时钟的全局样式*/
stroke:black;/*黑线*/
stroke-linecap:round;/*圆角*/
fill:#eef;/*以浅蓝灰色为背景*/
}
#face{stroke-width:3px;}/*时钟的外边框*/
#ticks{stroke-width:2;}/*标记每个小时的线段*/
#hourhand{stroke-width:5px;}/*相对较粗的时针*/
#minutehand{stroke-width:3px;}/*相对较细的分针*/
#numbers{/*如何绘制数字*/
font-family:sans-serif;font-size:7pt;font-weight:bold;
text-anchor:middle;stroke:none;fill:black;
}
</style>
</head>
<body onload="updateTime()">
<!--viewBox是坐标系,width和height是指屏幕大小-->
<svg id="clock"viewBox="0 0 100 100"width="500"height="500">
<defs><!--定义下拉阴影的滤镜-->
<filter id="shadow"x="-50%"y="-50%"width="200%"height="200%">
<feGaussianBlur in="SourceAlpha"stdDeviation="1"result="blur"/>
<feOffset in="blur"dx="1"dy="1"result="shadow"/>
<feMerge>
<feMergeNode in="SourceGraphic"/><feMergeNode in="shadow"/>
</feMerge>
</filter>
</defs>
<circle id="face"cx="50"cy="50"r="45"/><!--钟面-->
<g id="ticks"><!--12小时的刻度-->
<line x1='50'y1='5.000'x2='50.00'y2='10.00'/>
<line x1='72.50'y1='11.03'x2='70.00'y2='15.36'/>
<line x1='88.97'y1='27.50'x2='84.64'y2='30.00'/>
<line x1='95.00'y1='50.00'x2='90.00'y2='50.00'/>
<line x1='88.97'y1='72.50'x2='84.64'y2='70.00'/>
<line x1='72.50'y1='88.97'x2='70.00'y2='84.64'/>
<line x1='50.00'y1='95.00'x2='50.00'y2='90.00'/>
<line x1='27.50'y1='88.97'x2='30.00'y2='84.64'/>
<line x1='11.03'y1='72.50'x2='15.36'y2='70.00'/>
<line x1='5.000'y1='50.00'x2='10.00'y2='50.00'/>
<line x1='11.03'y1='27.50'x2='15.36'y2='30.00'/>
<line x1='27.50'y1='11.03'x2='30.00'y2='15.36'/>
</g>
<g id="numbers"><!--标记重要的几个刻度值-->
<text x="50"y="18">12</text><text x="85"y="53">3</text>
<text x="50"y="88">6</text><text x="15"y="53">9</text>
</g>
<!--初始绘制成竖直的指针,之后通过JavaScript代码来做旋转-->
<g id="hands"filter="url(#shadow)"><!--给指针添加阴影-->
<line id="hourhand"x1="50"y1="50"x2="50"y2="24"/>
<line id="minutehand"x1="50"y1="50"x2="50"y2="20"/>
</g>
</svg>
</body>
</html>
21.4 <canvas>中的图形
<canvas>元素自身是没有任何外观的,但是它在文档中创建了一个画板,同时还提供了很多强大的绘制客户端JavaScript的API。尽管canvas元素在HTML5中才标准化,但实际上它很早就存在了。<canvas>元素最早是Apple在Safari 1.3中引入的,Firefox 1.5之后以及Opera 9之后的浏览器都已经支持它了。Chrome的所有版本也都支持它。不过IE9之前的浏览器不支持<canvas>元素,但是可以使用开源的ExplorerCanvas项目(http://code.google.com/p/explorercanvas/)在IE6~8中模拟<canvas>元素。
<canvas>元素和SVG之间一个重要的区别是:使用canvas来绘制图形是通过调用它提供的方法而使用SVG绘制图形是通过构建一棵XML元素树来实现的。这两种方式都很强大:两者之间都可以互相模拟。但是,从表面上看,这两者还是不同的,并且各有优劣。比如:使用SVG来绘制图形,可以很简单地通过移除相应的元素来编辑图片。而使用<canvas>来绘制,要移除图片中的元素就不得不把当前的擦除再重新绘制一遍。Canvas的绘制API是基于JavaScript的,并且相对比较简洁(不像SVG语法那么复杂),因此本书对这些API都会做说明。参见第四部分中关于Canvas、CanvasRenderingContext2D和其他相关项的章节。
大部分的画布绘制API都不是在<canvas>元素自身上定义的,而是定义在一个“绘制上下文”对象上,获取该对象可以通过调用画布的getContext()方法。调用getContext()方法时,传递一个"2d"参数,会获得一个CanvasRenderingContext2D对象,使用该对象可以在画布上绘制二维图形。这里很重要的一点是要搞清楚,画布元素和它的上下文对象是两个完全不同的对象。由于CanvasRenderingContext2D名字太长了,因此这里做个约定,统一简称为“上下文对象”。同样地,“画布API”指的也就是CanvasRenderingContext2D对象的方法。
画布中的3D图形
在撰写本书时,浏览器提供商正在开始实现<canvas>元素用于绘制3D图形的API。这些API称为:"WebGL",它是绑定到OpenGL标准API的一个JavaScript。将"webgl"字符串作为参数传递给画布的getContext()方法可以获得用于绘制3D图形的上下文对象。由于WebGL很庞大,而且也非常复杂,本书将不会介绍它的一些底层API:其实Web开发者也更倾向于使用封装了WebGL底层API的工具类库而不喜欢直接使用WebGL API。
如下代码是一个使用画布API的简单例子,它在<canvas>元素中绘制一个红色的正方形和一个蓝色的圆,产生的输出和图21-2所示的SVG图形类似:
<body>
This is a red square:<canvas id="square"width=10 height=10></canvas>.
This is a blue circle:<canvas id="circle"width=10 height=10></canvas>.
<script>
var canvas=document.getElementById("square");//获取第一个画布元素
var context=canvas.getContext("2d");//获取2D绘制上下文
context.fillStyle="#f00";//设置填充色为红色
context.fillRect(0,0,10,10);//填充一个正方形
canvas=document.getElementById("circle");//第二个画布元素
context=canvas.getContext("2d");//获取它的绘制上下文
context.beginPath();//开始一条新的路径
context.arc(5,5,5,0,2*Math.PI,true);//将圆形添加到该路径中
context.fillStyle="#00f";//设置填充色为蓝色
context.fill();//填充路径
</script>
</body>
之前我们看到SVG使用可以绘制或填充的线段和曲线这种路径来描述复杂的图形。画布API也采用“路径”的思想。然而不同的是,相比SVG使用一个包含了字母和数字的字符串来描述路径,画布API是通过一系列方法调用来定义路径的,如上述代码中的beginPath()和arc()方法调用。一旦定义了路径,其他的诸如fill()这样的方法就可以在该路径上操作了。而像fillStyle这样的上下文对象的属性则是指定了如何进行这些操作。接下来的内容将解释:
·如何定义路径、如何绘制或者说勾勒路径的外边框以及如何填充路径的内部。
·如何设置和获取画布上下文对象的属性以及如何保存和恢复这些属性的当前状态。
·画布的大小、默认画布坐标系以及如何进行坐标变换。
·画布API定义的大量的绘制曲线的方法。
·一些用于绘制长方形的专用工具方法。
·如何指定颜色、使用透明度以及如何绘制渐变色和重复的图案。
·控制线条宽度以及顶点和端点外观的属性。
·如何在<canvas>元素中绘制文本。
·如何“裁剪”图形以保证图形不超过指定区域。
·如何给图形添加下拉阴影效果。
·如何在画布中绘制(和选择性地伸缩)图形以及如何作为图片从画布中提取内容。
·如何控制画布中新画(半透明)像素和原有像素的融合过程。
·在画布中,如何设置和查询像素的红、绿、蓝色值以及alpha值(透明度)。
·当在画布中绘制图形的时候,如何判定是否触发了鼠标事件。
本节最后会展示一个实际示例,其使用<canvas>元素绘制一个小的内联图表,一般称为“迷你图”(sparkline)。
下面大部分的<canvas>例子都使用到了变量c。该变量保存画布的CanvasRenderingContext2D对象,但是例子中并没有显示c是如何初始化的。要让这些例子能够正常运行,需要在HTML中定义个适当大小的画布,然后添加下面这段用于初始化变量c的代码:
var canvas=document.getElementById("my_canvas_id");
var c=canvas.getContext('2d');
下面所有的图都是通过JavaScript代码在<canvas>元素上绘制的——一般是在一个大的屏幕外画布中绘制高分辨率(达到印刷质量)的图形。
21.4.1 绘制线段和填充多边形
要在画布上绘制线段以及填充这些线段闭合的区域,从定义一条路径开始。路径有许多子路径组成,子路径又是由两个或多个点之间连接而成的线段组成(或者后面将介绍的曲线段)。调用beginPath()方法开始定义一条新的路径,而调用moveTo()方法则开始定义一条新的子路径。一旦使用moveTo()方法确定了子路径的起点,接下来就可以调用lineTo()方法来将该点与新的一个点通过直线连接起来。如下代码定义一条包含了两条线段的路径:
c.beginPath();//开始一条新路径
c.moveTo(100,100);//从(100,100)开始定义一条新的子路径
c.lineTo(200,200);//从(100,100)到(200,200)绘制一条线段
c.lineTo(100,200);//从(200,200)到(100,200)绘制一条线段
上述代码只是简单地定义一条路径,并没有在画布上绘制任何图形。要在路径中绘制(或者勾勒)两条线段,可以通过调用stroke()方法,要填充这些线段闭合的区域可以通过调用fill()方法:
c.fill();//填充一个三角形区域
c.stroke();//绘制三角形的两条边
上述代码(再加上一些设置线段宽度和填充颜色的代码)会渲染出图21-5所示图形:
图 21-5 一条绘制并填充的简单路径
要注意的是上述定义的子路径是“未闭合”的。它只包含两条线段,线段的终点并没有和起点汇合。也就是,它并没有闭合一个区域。对于这样“未闭合”的子路径,调用fill()方法填充的时候,会假设子路径的终点和子路径的起点是连接起来。这就是为什么,上述代码填充成了一个三角形,但是只勾勒了三角形的两条边。
想要勾勒出上述三角形的三条边,可以调用closePath()方法将子路径的起点和终点真正连接起来;还可以调用lineTo(100,100),但是,这样的话,最终表现出来的只是三条线段共用了一个起点和一个终点,但并未真正闭合。因此,当绘制比较粗的线段时,如果使用closePath()方法,视觉效果会更好。
关于stoke()方法和fill()方法还有另外非常重要的两点。第一点是:这两个方法都是作用在当前路径上的所有子路径。假设在上述代码中再加入如下代码:
c.moveTo(300,100);//在(300,100)上开始一条新的子路径
c.lineTo(300,200);//以(300,200)结束绘制一条竖直线段
如果再调用stroke()方法,就会绘制出三角形的两条相连的边,以及一条断开的竖直线段。
第二点是:stroke()方法和fill()方法都不更改当前路径。可以调用fill()方法,但是之后调用stroke()方法时候当前路径不变。完成一条路径后要再重新开始另一条路径,必须要记得调用beginPath()方法。如果没有调用beginPath()方法,那么之后添加的所有子路径都是添加在已有路径上,并且有可能重复绘制这些子路径。
例21-4定义了一个函数,用于绘制规则的多边形,同时展示了如何使用moveTo()、lineTo()和closePath()方法来定义子路径以及如何使用fill()方法和stoke()方法来绘制这些路径。最终绘制出来的图形如图21-6所示。
图 21-6 规则多边形
例21-4:使用moveTo()、lineTo()和closePath()方法绘制规则多边形
//定义一个以(x,y)为中心,半径为r的规则n边形
//每个顶点都是均匀分布在圆周上
//将第一个顶点放置在最上面,或者指定一定角度
//除非最后一个参数是true,否则顺时针旋转
function polygon(c,n,x,y,r,angle,counterclockwise){
angle=angle||0;
counterclockwise=counterclockwise||false;
c.moveTo(x+r*Math.sin(angle),//从第一个顶点开始一条新的子路径
y-r*Math.cos(angle));//使用三角法计算位置
var delta=2*Math.PI/n;//两个顶点之间的夹角
for(var i=1;i<n;i++){//循环剩余的每个顶点
angle+=counterclockwise?-delta:delta;//调整角度
c.lineTo(x+r*Math.sin(angle),//以下个顶点为端点添加线段
y-r*Math.cos(angle));
}
c.closePath();//将最后一个顶点和起点连接起来
}
//开始一个新的路径并添加一条多边形子路径
c.beginPath();
polygon(c,3,50,70,50);//三角形
polygon(c,4,150,60,50,Math.PI/4);//正方形
polygon(c,5,255,55,50);//五边形
polygon(c,6,365,53,50,Math.PI/6);//六边形
polygon(c,4,365,53,20,Math.PI/4,true);//六边形中的小正方形
//设置属属性来控制图形外观
c.fillStyle="#ccc";//内部使用浅灰色
c.strokeStyle="#008";//深蓝色外边框
c.lineWidth=5;//5个像素宽
//调用如下函数绘制所有这些多边形(每个分别定义在自己的子路径中)
c.fill();//填充图形
c.stroke();//勾勒外边框
要注意的是上述例子绘制了一个内部包含正方形的六边形。正方形和六边形是两条独立的子路径,但它们互相重叠。当出现该情况(或当单条子路径与自身相交)时,画布需要能够确定哪些区域在路径里面,哪些在外面。画布会采用“非零绕数原则”测试来判断它们。在上述例子中,由于六边形和正方形绘制的方向不同:六边形的顶点是沿着顺时针方向来连接的,而正方形顶点则是沿着逆时针连接的,因此根据“非零绕数原则”,对内部的正方形不进行填充。换句话说,如果正方形也沿着顺时针方向连接的话,调用fill()方法的时候就会对正方形也进行填充了。
非零绕数原则
要检测一个点P是否在路径的内部,使用非零绕数原则:想象一条从点P出发沿着任意方向无限延伸(或者一直延伸到路径所在的区域外某点)的射线。现在从0开始初始化一个计数器,然后对所有穿过这条射线的路径进行枚举。每当一条路径顺时针方向穿过射线的时候,计数器就加1;反之,就减1。最后,枚举完所有的路径之后,如果计数器的值不是0,那么就认为P是在路径内。反之,如果计数器的值是0,则认为P在路径外。
21.4.2 图形属性
例21-4设置了画布的上下文对象的fillStyle、strokeStyle以及lineWidth属性。这些属性都是图形属性,分别指定了调用fill()和stroke()时候要采用的颜色以及调用stroke()方法绘制线段时的线段宽度。要注意的是,这些参数不是传递给fill()和stroke()方法的,而是作为画布的通用图形状态。如果定义一个绘制形状的方法,但是该方法没有设置这些属性,那么调用者可以在调用该方法前,设置strokeStyle、fillStyle属性。这种将从将图形状态和绘制指令分离的细想是画布API中很重要的概念,同时也和通过在HTML文档中应用CSS样式来实现表现和内容分离是类似的。
画布API中在CanvasRenderingContext2D对象上定义了15个图形属性。表21-1中列出了这些属性,并对它们一一进行了说明。
因为画布API在上下文对象上定义图形属性,所以你也许试图多次调用getContext()方法来获取多个上下文对象。如果可以这样,能够在每个上下文中定义不同的属性:在每个上下文中,就好像拥有了不同的画笔,将会绘制出不同的颜色,或者不同宽度的线段。遗憾的是,在画布中不能这样使用。每个<canvas>元素只有一个上下文对象,因此每次调用getContext()方法都会返回相同的CanvasRenderingContext2D对象。
尽管画布API只允许一次设置单一的图形属性集合,但是它允许保存当前图形状态,这样就可以在多个状态之间切换,之后也可以很方便地恢复。调用save()方法会将当前图形状态压入用于已保存状态的栈上。调用restore()方法会从栈中弹出并恢复最近一次保存的状态。表21-1中列出的所有属性都是已保存状态的一部分,包括当前的转换信息以及裁剪区域等信息(两者都会在后面做介绍)都是已保存状态的一部分。但是,很重要的一点是:当前定义的路径以及不属于图形状态的当前点都不能保存和恢复。
如果需要比简单的图形状态栈允许的方式更加灵活,可以像例21-5那样定义一个工具方法:
例21-5:图形状态管理工具
//恢复最后一次保存的图形状态,但是让该状态从栈中弹出
CanvasRenderingContext2D.prototype.revert=function(){
this.restore();//恢复最后一次保存的图形状态
this.save();//再次保存它以便后续使用
return this;//允许方法链
};//通过o对象的属性来设置图形属性
//或者,如果没有提供参数,就以对象的方式返回当前属性
//要注意的是,它不处理变换和裁剪区域
CanvasRenderingContext2D.prototype.attrs=function(o){
if(o){
for(var a in o)//遍历o对象中的每个属性
this[a]=o[a];//将它设置成图形属性
return this;//启用方法链
}
else return{
fillStyle:this.fillStyle,font:this.font,
globalAlpha:this.globalAlpha,
globalCompositeOperation:this.globalCompositeOperation,
lineCap:this.lineCap,lineJoin:this.lineJoin,
lineWidth:this.lineWidth,miterLimit:this.miterLimit,
textAlign:this.textAlign,textBaseline:this.textBaseline,
shadowBlur:this.shadowBlur,shadowColor:this.shadowColor,
shadowOffsetX:this.shadowOffsetX,shadowOffsetY:this.shadowOffsetY,
strokeStyle:this.strokeStyle
};
};
21.4.3 画布的尺寸和坐标
<canvas>元素的width以及height属性和对应的画布对象的宽度以及高度属性决定了画布的尺寸。画布的默认坐标系是以画布最左上角为坐标原点(0,0)。越往右X轴的数值越大,越往下Y轴的数值越大。画布上的点可以使用浮点数来指定坐标,但是它们不会自动转换成整型值——画布采用反锯齿的方式来模拟部分填充的像素。
画布的尺寸是不能随意更改的,除非完全重置画布。重置画布的width属性或者height属性(哪怕重置的时候属性值不变),都会清空整个画布,擦除当前的路径并且会重置所有的图形属性(包括当前的变换和裁剪区域)为初始状态。
尽管画布尺寸是很重要的概念,但是设置了画布尺寸的大小,未必能够保证画布在屏幕上展现的大小或者组成画布绘图图面的所有像素点的个数一致。画布尺寸(以及默认的坐标系统)都是以CSS像素为单位的。CSS像素和常规的像素是一样的。然而,在高分辨率显示环境下,要求将多设备像素映射到单个CSS像素上。这也就意味着,画布上一个长方形的像素在高分辨率显示环境下可能要比它实际的大小要大。当使用画布的像素操作特性的时候(参见21.4.14节),尤其要深知这一点。但是,虚拟CSS像素和实际的硬件像素之间的区别对书写的画布代码没有任何影响。
默认情况下,<canvas>会按照它设置的HTML width和height属性值来显示画布大小(以CSS像素为单位)。但是,和其他HTML元素一样,<canvas>元素还可以通过CSS的width和height样式属性来设置它的屏幕显示大小。如果指定画布的屏幕显示大小和它的实际尺寸不同,那么画布上所有的像素都会自动缩放以适合通过CSS属性指定的屏幕显示尺寸。画布的屏幕显示大小不会影响画布位图的CSS像素或者硬件像素的个数,它的缩放是采用图片缩放方式处理的。如果屏幕显示尺寸要远远大于画布的实际尺寸,那么会导致像素化图形。这个问题需要图形设计师去考虑,和画布编程无关。
21.4.4 坐标系变换
此前提到过,默认坐标系是以画布最左上角为坐标原点(0,0)。越往右X轴的数值越大,越往下Y轴的数值越大。在默认坐标系中,每一个点的坐标都是直接映射到一个CSS像素上(CSS像素之后再映射到一个或者多个设备像素)。画布中一些特定的操作和属性的设置(诸如抽取原始像素值以及设置阴影偏移量)都使用默认坐标系。然而,除了默认的坐标系之外,每个画布还有一个“当前变换矩阵”,作为图形状态的一部分。该矩阵定义了画布的当前坐标系。当指定了一个点的坐标后,画布的大部分操作都会将该点映射到当前的坐标系中,而不是默认的坐标系。当前变换矩阵是用来将指定的坐标转换成为默认坐标系中的等价坐标。
尽管通过调用setTransform()方法能够直接设置画布的变换矩阵,但是通过转换、旋转和缩放操作更容易实现坐标系变换。图21-7展示了这些操作以及操作之后画布坐标系的样子。实现该图的程序其实只是将同一组坐标轴在一行中绘制了7遍。这7次绘制中唯一不同的只是坐标系不同而已。这里要注意的是,坐标的变换还影响了文本和线段的绘制。
调用translate()方法只是简单地将坐标原点进行上、下、左、右移动。调用rotate()方法会将坐标轴根据指定角度(画布API总是以弧度制来表示角度。要将角度制转换成弧度制,可以通过Math.PI来对180进行乘除来实现)进行顺时针旋转。调用scale()方法实现对X轴或者Y轴上的距离进行延长和缩短。
调用scale()方法的时候传递负值会实现以坐标原点做参照点将坐标轴进行翻转,就好像是镜子中的镜像。图21-7中最左下角的图就是这样实现的:translate()方法用来将坐标原点移动到画布最左下角,然后scale()方法用于实现将Y轴进行翻转,这样就变成了越往上Y轴的值越大。一个翻转过的坐标系和代数课上经常使用的坐标系类似,它有助于在图表上面绘制数据点。但是要注意的是,它同时也让文本变的难以阅读。
1.从数学角度来理解坐标系变换
我发现从几何学的角度很容易理解坐标变换,把translate()方法、rotate()方法以及scale()方法想象成如图21-7所示的对坐标轴的变换,就很容易理解了。从代数角度也很容易理解坐标变换,就是把变换想象成一个在变换后坐标系中的点(x,y),到了原来的坐标系统就变成了(x',y')。
调用c.translate(dx,dy)方法就等效于如下表达式:
x'=x+dx;//新系统中X轴的0,在原系统中就是dx
y'=y+dy;
图 21-7 坐标系变换
缩放操作也可以类似地作等效。调用c.scale(sx,sy)就等效于如下表达式:
x'=sx*x;
y'=sy*y;
旋转操作则相对比较复杂。调用c.rotate(a)可以通过三角法则等效于如下表达式:
x'=xcos(a)-ysin(a);
y'=ycos(a)+xsin(a);
要注意的是,坐标系变换是与顺序相关的。假设从画布默认的坐标系开始,然后进行变换,再进行伸缩。如此操作之后,要想将现有坐标系中的点(x,y)映射成默认坐标系中的点(x'',y''),必须首先应用等效的缩放等式把该点映射到未缩放坐标系中的一个中间点(x',y'),然后再使用等效的变换来将中间点再映射到原来坐标系中的点(x'',y'')。结果如下所示:
x''=sx*x+dx;
y''=sy*y+dy;
如果先调用scal()方法再调用translate()的话,那等效的结果就不同了:
x''=sx*(x+dx);
y''=sy*(y+dy);
这里要记住的最重要的一点是:从代数的角度去思考坐标变换的时候,必须是进行反向还原变换的(以相反的顺序来进行等效的变换)。而从几何角度去思考坐标变换的时候,是顺序去做变换的。
画布支持的这种变换称做“仿射变换”(affine transform)。仿射变换可能会修改点之间的距离和线段间的夹角,但是对于平行线而言,经过仿射变换后也始终保持平行——比如,不可能通过仿射变换来实现鱼眼镜头变形。任意的仿射变换可以利用a~f 6个参数等效描述成如下形式:
x'=ax+cy+e
y'=bx+dy+f
通过向transform()方法传递上述6个参数就可以应用任意仿射变换到当前的坐标系。图21-7展示的是两类变换——对指定点的扭曲和旋转——还可以像如下代码那样,使用transform()来实现相同的变换:
//扭曲变换:
//x'=x+kx*y;
//y'=y+ky*x;
function shear(c,kx,ky){c.transform(1,ky,kx,1,0,0);}//沿着点(x,y)顺时针旋转theta角度(弧度制)
//这也可以通过变换、旋转、变换序列操作来完成
function rotateAbout(c,theta,x,y){
var ct=Math.cos(theta),st=Math.sin(theta);
c.transform(ct,-st,st,ct,-xct-yst+x,xst-yct+y);
}
setTransform()方法和transform()方法接受同样的参数,但不同的是,前者不是对当前坐标系进行变换,而是对默认坐标系进行变换,并将结果映射到新的坐标系中。setTransform()对临时将画布重置为默认坐标系是很有用的:
c.save();//保存当前坐标系
c.setTransform(1,0,0,1,0,0);//恢复到默认坐标系
//使用默认的CSS像素坐标进行操作
c.restore();//恢复保存的坐标系
2.坐标系变换例子
例21-6证明了坐标变换的威力:通过递归调用translate()方法、rotate()方法以及scale()方法来实现绘制科赫雪花分形。例21-6的结果如图21-8所示,展示了0~4不同分形级别的科赫雪花。
图 21-8 科赫雪花
实现这些雪花的代码是非常简洁优雅的,但是由于使用了递归坐标变换,因此代码会比较难懂。即便没有注意到所有这些细微区别,这里要注意的是,代码仅包含了对lineTo()方法单次调用。图21-8中的每一条线段都是通过如下方式来绘制的:
c.lineTo(len,0);
len变量的值在代码执行的过程中是不会改变的,因此,线段的位置、方向和长度完全通过变换、旋转以及缩放等操作来指定。
例21-6:通过坐标系变换实现绘制科赫雪花
var deg=Math.PI/180;//用于角度制到弧度制的转换
//在画布的上下文c中,以左下角的点(x,y)和边长len,绘制一个n级别的科赫雪花分形
function snowflake(c,n,x,y,len){
c.save();//保存当前变换
c.translate(x,y);//变换原点为起始点
c.moveTo(0,0);//从新的原点开始一条新的子路径
leg(n);//绘制雪花的第一条边
c.rotate(-120*deg);//现在沿着逆时针方向旋转120o
leg(n);//绘制第二条边
c.rotate(-120*deg);//再次旋转
leg(n);//画最后一条边
c.closePath();//闭合子路径
c.restore();//恢复初始的变换
//绘制n级别的科赫雪花的一条边
//此函数在画完一条边的时候就离开当前点,
//然后通过坐标系变换将当前点又转换成(0,0,)
//这意味着画完一条边之后可以很简单地调用rotate()进行旋转
function leg(n){
c.save();//保存当前坐标系变换
if(n==0){//不需要递归的情况下:
c.lineTo(len,0);//就绘制一条水平线段
}
else{//递归情况下:绘制4条子边,类似这个样子:-\/-
c.scale(1/3,1/3);//子边长度为原边长的1/3
leg(n-1);//递归第一条子边
c.rotate(60*deg);//顺时针旋转60o
leg(n-1);//第二条子边
c.rotate(-120*deg);//逆时针旋转120o
leg(n-1);//第三条子边
c.rotate(60*deg);//通过旋转回到初始状态
leg(n-1);//最后一条边
}
c.restore();//恢复坐标系变换
c.translate(len,0);//但是通过转换使得边的结束点为(0,0)
}
}
snowflake(c,0,5,115,125);//0级别的雪花就是一个三角形
snowflake(c,1,145,115,125);//1级别的雪花就是一个六角星
snowflake(c,2,285,115,125);//依次类推
snowflake(c,3,425,115,125);
snowflake(c,4,565,115,125);//4级别的雪花看起来真的像一朵雪花了
c.stroke();//勾勒当前复杂的路径
21.4.5 绘制和填充曲线
路径由子路径组成,子路径又由连接的点组成。在2 1.4.1节中定义的路径中,那些点是通过直线段来连接的,但点与点之间并不总是通过直线段连接的。CanvasRenderingContext2D对象定义了一些方法,这些方法用于在子路径中添加新的点,并用曲线将当前点和新增的点连接起来。
arc()
此方法实现在当前子路径中添加一条弧。它首先将当前点和弧形的起点用一条直线连接,然后用圆的一部分来连接弧形的起点和终点,并把弧形终点作为新的当前点。要绘制一个弧形需要指定6个参数:圆心的X、Y坐标、圆的半径、弧形的起始和结束的角度以及弧形的方向(顺时针还是逆时针)。
arcTo()
此方法绘制一条直线和一段圆弧(和arc()方法一样),但是,不同的是,绘制圆弧的时候指定的参数不同。arc()方法参数需要指定点P1和P2以及半径。绘制的圆弧有指定的半径并且和当前点到P1的直线以及经过P1和P2的直线都相切。此种绘制圆弧的方法看似有点儿奇怪,但是对于绘制带有圆角的形状是非常有用的。当指定的半径为0时,此方法只会绘制一条从当前点到P1的直线。而当半径值非零时,此方法会绘制一条从当前点到P1的直线,然后将这条直线按照圆形形状变成曲线,一直到它指向P2方向。
bezierCurveTo()
此方法实现在当前子路径中添加一个新的点,并利用三次贝赛尔曲线将它和当前点相连。曲线的形状由两个“控制点”C1和C2确定。曲线从当前点开始,沿着C1点的方向延伸,再沿着C2的方向延伸一直到点P。曲线在这些点之间的过渡都是很平滑的。最后点P会成为当前点。
quadraticCurveTo()
此方法和bezierCurveTo()方法类似,不同的是它使用的是二次贝塞尔曲线而不是三次贝塞尔曲线并且只有一个控制点。
可以使用这些方法来绘制出如图21-9所示的图形:
图 21-9 画布中的曲线路径
例21-7展示了用于绘制图21-9所示图形的代码。代码中展示的一些方法是画布API中比较复杂的方法的一部分。想要了解这些复杂方法的细节以及它们的参数可以参见第四部分。
例21-7:在路径中添加曲线
//一个工具函数,用于将角度从角度制转化成弧度制
function rads(x){return Math.PI*x/180;}//绘制一个圆形,如果需要椭圆的话则进行相应的缩放和旋转即可
//由于没有当前点,因此绘制的圆形不需要当前点到圆形起点之间的直线
c.beginPath();
c.arc(75,100,50,//圆心位于(75,100),半径为50
0,rads(360),false);//从0o到360o顺时针旋转
//绘制一个楔,角度从x轴正向顺时针度量
//要注意的是arc()方法会将当前点和弧形起点用直线相连
c.moveTo(200,100);//从圆心开始
c.arc(200,100,50,//圆心和半径
rads(-60),rads(0),//从-60o开始一直到0o
false);//false表示顺时针
c.closePath();//将半径添加到圆心
//同样的楔,但是方向不同
c.moveTo(325,100);
c.arc(325,100,50,rads(-60),rads(0),true);//逆时针
c.closePath();//使用arcTo()方法来绘制圆角,绘制一个以点(400,50)为左上角同时还带有不同半径角的正方形
c.moveTo(450,50);//从上边的中点开始
c.arcTo(500,50,500,150,30);//添加部分上边和右上角
c.arcTo(500,150,400,150,20);//添加右上角和右下角
c.arcTo(400,150,400,50,10);//添加底边和左下角
c.arcTo(400,50,500,50,0)//添加左边和左上角
c.closePath();//闭合路径来添加其余的上边
//二次贝塞尔曲线:一个控制点
c.moveTo(75,250);//从点(75,250)开始
c.quadraticCurveTo(100,200,175,250);//画一条以一直到点(175,250)结束的曲线
c.fillRect(100-3,200-3,6,6);//标记控制点(100,200)
//三次贝塞尔曲线
c.moveTo(200,250);//从点(200,250)开始
c.bezierCurveTo(220,220,280,280,300,250);//画一条以一直到点(300,250)结束的曲线
c.fillRect(220-3,220-3,6,6);//标记控制点
c.fillRect(280-3,280-3,6,6);//定义一些图形属性并绘制曲线
c.fillStyle="#aaa";//填充灰色
c.lineWidth=5;//5个像素宽的黑色(默认颜色)线段
c.fill();//填充该曲线
c.stroke();//勾勒外边框
21.4.6 矩形
CanvasRenderingContext2D对象定义了4个用于绘制矩形的方法。例21-7使用了其中一个方法:fillRect(),来标记贝塞尔曲线的控制点。这4个绘制矩形的方法都接受两个参数,其中一个指定矩形的一个顶点,另一个参数指定矩形的宽和高。一般都是指定矩形的左上角顶点,然后再传递表示一个宽度和高度的正值,当然也可以指定其他的顶点然后传递表示宽度和高度的负值。
fillRect()方法使用当前的fillStyle来填充指定的矩形。strokeRect()方法使用当前的strokeStyle和其他线段的属性来勾勒指定矩形的外边框。clearRect()方法和fillRect()方法类似,但是不同的是,它会忽略当前填充样式,采用透明的黑色像素(所以空白画布的默认颜色)来填充矩形。这里重要的一点是:这三个方法都不影响当前路径以及路径中的当前点。
最后一个用于绘制矩形的方法是rect(),此方法会对当前路径产生影响:它会在将指定的矩形添加到当前路径的子路径中。和其他用于定义路径的方法一样,它本身不会自动做任何和填充以及勾勒相关的事情。
21.4.7 颜色、透明度、渐变以及图案
stokeStyle和fillStyle属性指定了线条勾勒的样式和区域填充的样式。大部分情况下,这些属性用于指定不透明或者半透明情况下的颜色,但是,也可以将它们设置成CanvasPattern或者CanvasGradient对象,以实现采用重复的背景图片或线性或辐射型的渐变色来进行勾勒或者填充。除此之外,还可以通过设置globalAlpha属性使得所有绘制的东西都变成半透明。
要指定一种纯色,可以使用HTML4标准[1]定义的颜色名字或者使用CSS颜色串:
context.strokeStyle="blue";//用蓝色勾勒线段
context.fillStyle="#aaa";//用浅灰色填充区域
strokeStyle和fillStyle属性的默认值都是"#000000":不透明黑色
目前,支持CSS3颜色的浏览器除了允许标准的16进制RGB颜色之外,还允许使用RGB、RGBA、HSL和HSLA颜色空间。如下是一些例子:
var colors=[
"#f44",//十六进制RGB色值:红色
"#44ff44",//十六进制RRGGBB色值:绿色
"rgb(60,60,255)",//用0~255之间的整数来表示的RGB色值:蓝色
"rgb(100%,25%,100%)",//用百分比来表示的RGB色值:紫色
"rgba(100%,25%,100%,0.5)",//RGB加上0~1的alpha值:半透明紫色
"rgba(0,0,0,0)",//全透明黑色
"transparent",//和上述类似
"hsl(60,100%,50%)",//全饱和黄色
"hsl(60,75%,50%)",//低饱和黄色
"hsl(60,100%,75%)",//全饱和暗黄色
"hsl(60,100%,25%)",//全饱和亮黄色
"hsla(60,100%,50%,0.5)",//全饱和黄色,50%不透明度
];
HSL颜色空间采用三个数字来指定颜色,这个三个数字分别代表色调、饱和度和亮度。其中色调是颜色轮周围的度数。色调是0°表示红色,60°表示黄色,120°表示绿色,180°表示靛色,240°表示蓝色,300°表示品红色,360°再次转回红色。饱和度描述的是颜色的强度,它是以百分比来表示的。饱和度为0的颜色就是暗灰色。亮度描述的是一种颜色多么的明亮或者多么的暗淡,它也是以百分比来表示的。任何HSL颜色,凡是亮度为100%的都是纯白色,同样,任何亮度为0的颜色都是纯黑色。HSLA颜色空间和HSL类似,只是前者增加了一个alpha值,它的取值范围从0.0(透明)~1.0(不透明)。
如果又想要半透明的颜色,又不想显式地给每种颜色都设置一个透明通道的话,又或者想要给透明的图片或者图案添加半透明效果(比方说)的话,可以通过设置globalAlpha属性。这样,每一个绘制的像素都会将其alpha值乘以设置的globaAlpha值。globalAlpha属性默认值是1,表示不透明。如果将其值设置为0的话,那么所有绘制的图形都会变成全透明,这样一来,看上去画布上就什么也没有。而如果设置为0.5的话,那么所有绘制的原本不透明的像素都会变成50%的不透明度。而如果原本像素是50%不透明度的话就变成25%的不透明度。如果设置了globalAlpha,所有的像素都会变成半透明,这个时候不得不去考虑,像素的重叠问题——可以参见21.4.13节来了解关于画布合成模型相关的细节。
如果不想绘制纯色(也许是半透明的),可以使用渐变和重复图片来填充和勾勒路径。图21-10所示的矩形,用宽线条来勾勒,上面采用线性渐变填充,下面则使用半透明的辐射状渐变填充。下面的代码片段展示了这些图案和渐变是如何创建出来的。
要使用背景图片的图案而不是颜色来填充或者勾勒,可以将fillStyle或者strokeStyle属性设置成CanvasPattern对象,该对象可以通过调用上下文对象的createPattern()方法返回。
var image=document.getElementById("myimage");
c.fillStyle=c.createPattern(image,"repeat");
createPattern()方法的第一个参数指定了用做图案的图片。它必须是文档中的一个<img>元素、<canvas>元素或者<video>元素(或者是通过Image()构造函数创建出来的图片对象)。第二个参数通常是"repeat",表示采用重复的图片填充,这和图片大小是无关的。除此之外,还可以使用"repeat-x"、"repeat-y"或者"no-repeat"。
要注意的是还可以采用一个<canvas>元素(甚至是一个从未添加到文档中并且不可见的<canvas>元素)作为另外一个<canvas>元素的图案:
var offscreen=document.createElement("canvas");//创建一个屏幕外画布
offscreen.width=offscreen.height=10;//设置它的大小
offscreen.getContext("2d").strokeRect(0,0,6,6);//获取它的上下文并进行绘制
var pattern=c.createPattern(offscreen,"repeat");//将它用做图案
图 21-10 以图案和渐变进行填充
要使用渐变色来进行填充或勾勒,可以将fillStyle属性(或者strokeStyle属性)设置为一个CanvasGradient对象,该对象可以通过调用上下文对象上的createLinearGradient()或createRadialGradient()方法来返回。创建渐变色需要通过好几个步骤,同时使用渐变色也要比使用图案更加巧妙。
第一步是要创建一个CanvasGradient对象。createLinearGradient()方法需要的参数是定义一条线段(不一定要水平或者垂直)两个点的坐标,这条线段上每个点的颜色都不同。createRadialGradient()方法需要的参数是两个圆(这两个圆不一定要同心圆,但是一般第二个圆完全包含第一个圆)的圆心和半径。小圆内的区域和大圆外的区域都会用纯色来填充:而两圆之间的区域会用渐变色来填充。
在创建了CanvasGradient对象以及定义了画布中要填充的区域之后,必须通过调用CanvasGradient对象的addColorStop()方法来定义渐变色。该方法的第一个参数是0.0~1.0之间的一个数字,第二个参数是一个CSS颜色值。必须至少调用该方法两次来定义一个简单的颜色渐变,但是可以调用它多次。在0.0位置的颜色会出现在渐变的起始,在1.0位置的颜色会出现在渐变色最后。如果还指定其他的颜色,那么它们会出现在渐变指定的小数位置。其他地方的颜色会进行平滑的过渡。下面是一些例子:
//一个线性渐变,沿着画布的对角线(假设没有进行坐标系变换)
var bgfade=c.createLinearGradient(0,0,canvas.width,canvas.height);
bgfade.addColorStop(0.0,"#88f");//以左上角为亮蓝色开始
bgfade.addColorStop(1.0,"#fff");//一直到右下角以白色结束
//两个同心圆之间的一种渐变,中间为透明色,然后慢慢变为灰色半透明,最后再回到透明色
var peekhole=c.createRadialGradient(300,300,100,300,300,300);
peekhole.addColorStop(0.0,"transparent");//透明
peekhole.addColorStop(0.7,"rgba(100,100,100,.9)");//灰色半透明
peekhole.addColorStop(1.0,"rgba(0,0,0,0)");//再次透明
关于渐变要明白的很重要的一点是:它们并不是与位置相关的。当创建一种渐变的时候,需要指定渐变的范围。如果试图填充渐变指定范围外的区域,会以渐变最后结束的纯色或者另一个渐变色来填充。比如,定义了一种沿着点(0,0)到(100,100)的渐变,那么该渐变只能用于填充在矩形(0,0,100,100)范围内的对象。
图21-10所示的图形是使用如下代码创建的,该代码采用了pattern模式,同时使用到了之前定义过的bgfade和peekhole渐变:
c.fillStyle=bgfade;//以线性渐变开始
c.fillRect(0,0,600,600);//填充整个画布
c.strokeStyle=pattern;//使用图案来勾勒线段
c.lineWidth=100;//使用非常宽的线段
c.strokeRect(100,100,400,400);//绘制一个大的正方形
c.fillStyle=peekhole;//切换到辐射状渐变
c.fillRect(0,0,600,600);//使用半透明填充来遮罩画布
21.4.8 线段绘制相关的属性
前面已经介绍过了lineWidth属性,它用于指定通过stroke()方法和strokeRect()方法绘制时线段的宽度。除了lineWidth(当然还有strokeStyle)属性之外,还有其他三个图形属性影响绘制线段。
lineWidth属性的默认值是1,可以将该属性设置成任意正数,甚至是小于1的小数。(小于1个像素宽的线段会绘制成半透明色的,这样它们就看起来比一个像素宽的线段更暗)。要想完全搞清楚lineWidth属性,将路径视为是很多无限细的1维线条是很重要的。而通过调用stoke()方法绘制的线段或者曲线是处于路径的中间,两边都是lineWidth宽度的一半。如果勾勒一条闭合的路径并只希望线段出现在路径之外,那么首先勾勒该路径,然后用不透明的颜色填充闭合区域来将出现在路径内的勾勒部分隐藏。又或者如果只希望线段出现在闭合路径内,那么首先调用save()方法和clip()(参见21.4.10节)方法,然后调用stroke()方法和restore()方法。
线段宽度是受当前坐标系变换影响的,正如图21-7所示,可以通过坐标系变换来对坐标轴进行缩放。如果调用了scale(2,1)方法就会对X轴进行缩放,但是对Y轴不产生影响,这样一来,垂直的线段要比原先和它一样宽的水平线段宽一倍。这里很重要的是要搞清楚:当调用stroke()方法时候,线段宽度是由lineWidth属性以及当前的坐标系变换决定的,而与lineTo()方法或者其他用于创建路径的方法无关。
另外三个与线段绘制相关的属性影响路径中未连接的端点的外观以及两条路径相交顶点的外观。它们对于很窄的线段的影响很小,相比而言,对于相对较宽的线段的影响很大。如图21-11所示,它展示了一条细的黑线路径并在周围用灰色区域进行勾勒。该图展示了其中两个属性。
图 21-11 lineCap属性和lineJoin属性
lineCap属性指定了一个未封闭的子路径段的端点如何“封顶”。该属性的默认值"butt"表示线段端点直接结束。"square"值则表示在端点的基础上,再继续延长线段宽度一半的长度。"round"值则表示在端点的基础上延长一个半圆(圆的半径是线段宽度的一半)。
lineJoin属性指定了子路径顶点之间如何连接。其默认值是"miter",表示一直延伸两条路径段的外侧边缘直到在某一点汇合。"round"值则表示将汇合的顶点变得圆润,"bevel"值则表示用一条直线将汇合的顶点切除。
最后一个与线段绘制相关的属性是miterLimit,它只有当lineJoin属性值是"miter"才会起作用。当两条线段相交的夹角是锐角的时候,两条线段的斜接部分可以变得很长[2],并且这些锯齿状的斜接部分在视觉上是分离的。miterLimit属性指定斜接部分长度的上限。如果指定点上的斜接长度比线段宽度乘以指定的miterLimit值的一半还要长的话,最终绘制出来的顶点就会是斜切的而不是斜接的。
21.4.9 文本
要在画布上绘制文本,通常使用fillText()方法来使用fillStyle属性指定的颜色(渐变或者图案)绘制文本。要想在大字号文本上加特效,可以使用strokeText()方法,该方法会在每个字形外边绘制轮廓(图21-13就是一个带轮廓的文本的例子)。fillText()方法和strokeText()方法都接受要绘制的文本内容作为第一个参数,以及文本绘制位置的X轴坐标和Y轴坐标作为第二个和第三个参数。但是这两个方法都不会对当前路径和当前点产生影响。如图21-7所示,文本是会受当前坐标系变换所影响的。
font属性指定了绘制文本时候采用的字体。该属性值是一个字符串,语法和CSS的font属性一致。下面是一些例子:
"48pt sans-serif"
"bold 18px Times Roman"
"italic 12pt monospaced"
"bolder smaller serif"//比<canvas>的字体更加粗或者更加细
textAlign属性指定了文本应当参照X轴坐标(调用fillText()或者strokeText()方法时候传入的参数)如何进行水平对齐。textBaseline属性则指定了文本应当参照Y轴坐标如何进行垂直对齐。图21-12展示了这两个属性的可能值,每个文本字符串下面的细线就是基线(baseline),那个小方框标记了传递给fillText()方法的点(x,y)。
图 21-12 textAlign属性和textBaseline属性
textAlign属性的默认值是"start"。要注意的是:对于从左到右的文本而言,"start"方式的对齐和"left"方式的对齐是一样的,"end"方式的对齐和"right"方式的对齐是一样的。但是,如果设置<canvas>元素的dir属性为"rtl"(right-to-left),那么"start"方式的对齐和"right"方式的对齐是一样的,同样"end"方式的对齐和"left"方式的对齐是一样的。
textBaseline属性的默认值是"alphabetic",它适合用于拉丁语系和其他类似语系的字母。"ideographic"值用于诸如中文和日文之类的表意文字。"hanging"值则是用于梵文和类似的文字(大多用于印度语)。"top"、"middle"以及"bottom"这样的基线都是纯几何基线,它们都是基于设置的字体的"emsquare"。
fillText()方法和strokeText()方法同时还接受第4个可选的参数。该参数指定文本展现的最大宽度。当使用font属性绘制文本的时候,如果文本宽度比指定宽度大,那么画布会通过缩放或者采用更窄或更小的字体。
如果想要在绘制文本前自己先度量文本的宽度,那么可以使用measureText()方法。该方法返回一个TextMetrics对象,它指定在使用当前字体绘制文本时的尺寸。截止撰写本书时,TextMetrics对象中包含的唯一"metric"的是width。可以通过如下方式来获取一个字符串的屏幕显示宽度:
var width=c.measureText(text).width;
21.4.10 裁剪
在定义一条路径之后,通常会调用stroke()方法或者fill()方法(或者两者都调用)。除此之外,还可以调用clip()方法来定义一个裁剪区域。一旦定义了一个裁剪区域,在该区域外将不会绘制任何内容。图21-3展示了一个使用了裁剪区域来绘制的图形,在该图形中,勾勒中间的竖直条带以及下面的文字时都没有使用裁剪区域,而在填充三角形之前,定义了三角形裁剪区域。
图21-13是使用了例21-4中的polygon()方法来生成的,代码如下所示:
//定义一些绘制属性
c.font="bold 60pt sans-serif";//大号字体
c.lineWidth=2;//窄线段
c.strokeStyle="#000";//黑色线段
//勾勒矩形轮廓和文本轮廓
c.strokeRect(175,25,50,325);//中间竖直的条带
c.strokeText("<canvas>",15,330);//注意使用的是strokeText()方法而不是fillText()方法
//在外部定义一条包含内部的复杂路径
polygon(c,3,200,225,200);//大三角形
polygon(c,3,200,225,100,0,true);//在内部再绘制一个小三角形
//将该路径定义成裁剪区域
c.clip();//用5个像素宽的线段来勾勒路径,完全在裁剪区域内
c.lineWidth=10;//另外5个像素的线段被裁剪了
c.stroke();//填充在裁剪区域内的矩形部分和文本部分
c.fillStyle="#aaa"//暗灰色
c.fillRect(175,25,50,325);//填充竖直的条带
c.fillStyle="#888"//深灰色
c.fillText("<canvas>",15,330);//填充文本
图 21-13 未裁剪的勾勒和裁剪的填充
要注意很重要的一点是:当调用clip()方法时,当前路径自身就会裁剪到当前裁剪区域中,之后,被裁剪的路径就变成了新的裁剪区域。这意味着,clip()方法只会缩小裁剪区域,永远不会放大裁剪区域。由于没有提供重置裁剪区域的方法,因此在调用clip()之前通常要调用save()方法,以便于之后恢复未裁剪区域。
21.4.11 阴影
CanvasRenderingContext2D对象定义了4个图形属性用于控制绘制下拉阴影。如果正确设置这些属性,绘制的任何线段、区域、文本以及图片都会拥有下拉阴影,这样外观上看起来就像它浮出了画布表面。图21-14展示了填充的矩形下的阴影、勾勒的矩形下的阴影以及填充的文本下的阴影。
shadowColor属性指定阴影的颜色。其默认值是完全透明的黑色,因此如果没有将该属性设置为半透明色或者不透明色,阴影都是不可见的。该属性只能设置为一个表示颜色的字符串:图案和渐变都是不允许用于阴影的。使用半透明色的阴影可以产生很逼真的阴影效果,因为透过它还能够看到背景。
shadowOffsetX属性和shadowOffsetY属性指定阴影的X轴和Y轴的偏移量。这两个属性的默认值都是0,表示直接将阴影绘制在图形正下方,在这种位置阴影是不可见的。如果将这两个属性都设置为一个正值,那么阴影会出现在图形的右下角位置,就好像有一个左上角的光源从计算机屏幕外面照射到画布上。偏移量越大,产生的阴影也越大,同时会感觉绘制的物体在画布上浮得也越高。
shadowBlur属性指定了阴影边缘的模糊程度。其默认值为0,表示产生一个清晰明亮的阴影。该属性值越大表示阴影越模糊。该属性是高斯模糊函数的一个参数,和像素的大小以及长度无关。
例21-8所示代码是用于绘制图21-4所示图形。该段代码展示了如何使用这4个用于绘制阴影的属性。
图 21-14 自动生成的阴影
例21-8:设置阴影属性
//定义一种不明显的阴影
c.shadowColor="rgba(100,100,100,.4)";//半透明灰色
c.shadowOffsetX=c.shadowOffsetY=3;//偏移阴影到右下角部分
c.shadowBlur=5;//柔化阴影的边缘
//使用阴影在一个蓝色的方框中绘制一些文本
c.lineWidth=10;
c.strokeStyle="blue";
c.strokeRect(100,100,300,200);//绘制一个矩形
c.font="Bold 36pt Helvetica";
c.fillText("Hello World",115,225);//绘制一些文本
//定义一个模糊点的阴影。较大的偏移量使绘制的物体浮得越高
//要注意透明的阴影是如何和蓝色的方框重叠的
c.shadowOffsetX=c.shadowOffsetY=20;
c.shadowBlur=10;
c.fillStyle="red";//绘制一个纯红色的矩形
c.fillRect(50,25,200,65);//该红色矩形浮在蓝色方框上面
shadowOffsetX属性和shadowOffsetY属性总是在默认的坐标空间中度量的,它不受rotate()方法和scale()方法的影响。比如,假设先将坐标系旋转90°之后绘制了一些竖直的文本,之后再恢复到原先的坐标系统再绘制一些水平的文本。这样,竖直的文本和水平的文本的阴影都是朝向一个方向的,这或许也正是想要的效果。同样地,通过不同的缩放变换绘制的图形拥有的阴影都有相同的“高度”[3]。
21.4.12 图片
除了矢量图形(路径、线段等)之外,画布API还支持位图图片。drawImage()用于将源图片(或者源图片中的矩形区域中)的像素内容复制到画布上,有需要的时候可以对图片进行缩放和旋转。
调用drawImage()方法的时候可以传递3个、5个或者9个参数。其中第一个参数是要将其像素复制到画布上的源图片。这个图片参数通常是一个<img>元素或者通过Image()构造函数创建的一张屏幕外图片,但是它还可以是另一个<canvas>元素或者甚至是一个<video>元素。如果指定的<img>或者<video>元素正在加载数据,那么调用drawImage()方法什么也不做。
如果传递3个参数给drawImage()方法,那么第二个和第三个参数指定待绘制图片的左上角位置的X轴和Y轴坐标。以这种方式调用的话,源图片的所有内容都会复制到画布上。指定的X轴和Y轴坐标会相应地转换到当前的坐标系中,如果有需要的话可以对图片进行缩放和旋转。
如果传递5个参数给drawImage()方法,那么另外两个参数分别是宽度和高度。X轴和Y轴坐标以及宽度和高度,这4个参数在画布上定义了一个目标矩形局域。图片的左上角定位在点(x,y),而其右下角则定位在点(x+width,y+height)。同样,这种调用方式也会复制整个源图片。该目标矩形区域会在当前坐标系中度量,而即使不指定缩放变换源图片也会自动伸缩适应目标矩形区域。
如果传递9个参数给drawImage()方法,那么这些参数还同时指定了一个源矩形区域和一个目标矩形区域,并且只会复制源矩形区域内的像素。其中第2~5个参数指定了源矩形区域。它们是以CSS像素来度量的。如果指定的源图片是另一个画布,那么源矩形区域会使用该画布的默认坐标系,并会忽略指定的任何变换。第6~9个参数指定了图片要绘制在的目标矩形区域,该区域是在画布当前的坐标系而不是默认的坐标系中绘制的。
例21-9是一个使用drawImage()的简单例子。它使用9个参数来调用drawImage()方法,从一个画布部分区域中复制像素并将它们绘制出来,同时在同相同画布上进行放大和旋转。正如图21-15所示的那样,为了明显看出像素化,已经将图片放到了足够大,这时候可以看出其中使用了半透明像素来使得线条边缘变得更加平滑。
图 21-15 使用drawImage()将像素放大
例21-9:使用drawImage()方法
//在左上角绘制一条线段
c.moveTo(5,5);
c.lineTo(45,45);
c.lineWidth=8;
c.lineCap="round";
c.stroke();//定义一个变换
c.translate(50,100);
c.rotate(-45*Math.PI/180);//让线段变得更直
c.scale(10,10);//将它放大到能够看到每个像素
//使用drawImage()方法来复制该线段
c.drawImage(c.canvas,
0,0,50,50,//源矩形区域:未变换
0,0,50,50);//目标矩形区域:变换过
除了能将一张图片绘制到一张画布中之外,还能使用toDataURL()方法将画布中内容抽取成一张图片。和这里介绍的其他方法不同,toDataURL()方法是画布元素自身的方法,而不是CanvasRenderingContext2D对象的方法。通常调用toDataURL()方法的时候不传递任何参数,它会将画布内容以PNG图片的形式返回,同时编码成一个字符串数据,用URL表示。返回的URL可以在<img>元素中使用,同时也可以使用如下代码来实现画布静态截图功能:
var img=document.createElement("img");//创建一个<img>元素
img.src=canvas.toDataURL();//设置其src属性
document.body.appendChild(img);//把它追加到文档后面
所有浏览器都要求支持PNG图片格式。其中有些浏览器可能还支持其他的图片格式,可以通过利用toDataURL()方法的第一个可选参数来指定需要图片格式的MIME类型。想要了解详细内容,可以参见第四部分。
当使用toDataURL()方法的时候,必须要知道它有一个很重要的安全限制。为了避免跨域的信息泄露,toDataURL()方法无法在非"origin-clean"的<canvas>元素上使用的。这里所谓的非"orign-clean"指的是:一张画布上绘制的图片(直接调用drawImage()方法绘制或者间接通过CanvasPattern绘制)和画布所在的文档不属于同源。
21.4.13 合成
当勾勒线段、填充区域或者复制图片的时候,会想要让新绘制的像素点能够在画布中原有像素的上面。如果绘制一个不透明的像素,它们会替换同一位置原有的像素。如果绘制的是半透明的像素,那么新(“源”)像素会和原(“目标”)像素进行合并,原像素可以透过新像素看到,而清晰程度取决于像素的透明度。
合并新的半透明源像素和已有目标像素的过程称为“合成”,上面描述的合成过程也是画布API定义的默认像素合并方式。但是,有的时候其实是不希望进行合成的。比如,已经使用半透明像素在画布中绘制了一些内容,这个时候想要进行临时切换,然后再恢复到原先的状态。这个时候最简单的方法就是:将使用drawImage()方法将画布内容(或者画布一部分区域内容)复制到一张屏幕外画布中。然后,在需要恢复画布的时候,再从屏幕外画布中将内容复制回到屏幕上的画布中。但是,要记住的是,保存的像素都是半透明。如果这个时候合成是开启的,它们并不会完全抹除临时绘制的内容。因此,在上述情况下,就需要一种方式将合成关闭:不论源像素是否透明,都绘制源像素并忽略目标像素。
要指定合成的方式,可以设置globalCompositeOperation属性。该属性的默认值是"source-over",表示将源像素绘制在目标像素上,对于半透明的源像素就直接合并。如果将该属性设置为"copy",则表示关闭合成:源像素将原封不动地复制到画布上,直接忽略目标像素。globalCompositeOperation属性还有另一个有时相当有用的属性值——"destination-over",表示将新的源像素绘制在已有目标像素的下面。如果目标像素是半透明或者透明的话,所有或者部分源像素的颜色在最终颜色上就是可见的。
"source-over"、"destination-over"和"copy"是三种最常用的合成类型,而事实上画布API支持globalCompositeOperation属性的11个值。直接看这些属性值的名字就大概知道它们是怎样的合成方式了,当然,也可以结合一些实际的例子来理解它们的工作原理,不过这个过程可能会比较漫长。图21-16展示了这11种合成方式各自的效果,演示完全使用“硬”透明度:所有这些绘制的像素要么是完全透明要么是完全不透明。在这11个方框中,都是先绘制正方形,将其作为目标。然后再设置globalCompositeOperation属性,最后绘制圆形,将其作为源。
图 21-16 使用“硬”透明度实现合成操作
图21-17是一个相似的例子,不同的是使用的是“软”透明度。在该例中,作为原始图形的圆形和作为目的图形的正方形都是采用渐变色来绘制的,以便每个像素的透明度都会不同。
如图21-17所示,使用半透明像素绘制时,可能就会发现要搞明白合成操作不是那么容易的。如果想要更深入地了解合成操作,在第四部分有专门对CanvasRenderingContext2D对象的合成操作做详细讲解的,它解释了11种合成方式中每个单独的像素值是如何从源像素和目的像素计算的。
图 21-17 使用“软透明度”进行合成操作
截至撰写本书时,浏览器提供商在实现11种合成方式中的5种上意见不统一:"copy"、"source-in"、"source-out"、"destination-atop"和"destination-in"在不同的浏览器上表现不同,无法做到兼容。下面会具体讲解表现如何不同,但是,如果你不打算使用这些合成操作的话,完全可以跳过下一节的内容。
上述这5种合成模式在计算合成结果的时候,要么是在计算结果像素值的时候直接忽略目标像素值,要么是将任何源像素中透明的地方全部变透明。两者在实现上的区别就在于源像素的定义上。Safari和Chrome进行合成的时候,是“局部”操作的:只有真正通过fill()方法、stroke()方法或者其他绘制操作绘制出来的像素才能算是源像素。IE9的实现方式似乎也是类似的。而Firefox和Opera是进行“全局”合并的:对于每次绘制操作,在当前裁剪区域中的所有像素都会进行合成。如果源像素没有设置该像素,默认会按透明黑色处理。在Firefox和Opera中,这就意味着,上述5种合成模式实际上都会将在源像素外又在裁剪区中的像素都抹除。图21-16和图21-17就是在Firefox显示的效果。这也就是为什么采用"copy"、"source-in"、"source-out"、"destination-atop"以及"destination-in"方式合成的方框周围都要比其他的方框要细:每个例子周围的矩形指的是裁剪区域,而这5种合并操作将落在裁剪区域内的线段(lineWidth宽度的一半)给抹除了。为了作比较,图21-18显示了和图21-17同样的图形,不同的是它是显示在Chrome中的。
图 21-18 采用“局部”合成的方式而不是“全局”合成的方式
截至撰写本书时,HTML5标准的草案中定义的是由Firefox和Opera实现的全局合成。浏览器提供厂商也意识到了不兼容性,同时对当前的标准也表示不满。而之后标准很有可能采纳局部合成的方式,而不是全局合成的方式。
最后,要注意的是,在像Safari和Chrome这样实现本地合成方式的浏览器中也可以使用全局合成方式。首先,创建一个和屏幕显示的画布同样尺寸的屏幕外画布。然后在该屏幕外画布中绘制一些源像素,并使用drawImage()方法将这些屏幕外像素复制到屏幕显示的画布中,这样就能在裁剪区域中实现全局合成了。但是没有一个通用的方法在像Firefox这样实现全局合成的浏览器中执行本地合成模式,但是通常也能够通过在要进行的本地合成方式的绘制操作之前先定义一个适当的裁剪区域来近似的模拟。
21.4.14 像素操作
调用getImageData()方法会返回一个ImageData对象,该对象表示画布矩形区域中的原始(没有预先进行像素增加处理的)像素信息(由R、G、B和A分量组成)。使用createImageData()方法可以创建一个空的ImageData对象。ImageData对象中的像素是可写的,因此可以对它们进行随心所欲的设置,然后再通过putImageData()方法将这些像素复制回画布中。
这些像素操作方法提供了对画布的底层访问。传递给getImageData()方法的矩形是在默认的坐标系中的:它的尺寸以CSS像素为单位来度量并且不受当前坐标系变换的影响。当调用putImageData()方法时,指定的位置也是按照默认的坐标系来处理的。而且,putImageData()方法会忽略所有的图形属性。它不会进行任何合成操作,也不会用globalAlpha乘以像素来显示,更不会绘制阴影。
像素操作方法对于实现图片处理来说是很有用的。例21-10展示了如何在一张画布中的图形上创建一种简单的动态模糊或者“涂抹”效果。该例展示了如何使用getImageData()方法和putImageData()方法以及如何在一个ImageData对象中迭代和修改像素色值,但是它没有对这些进行详细的解释。想要了解getImageData()方法和putImageData()方法的全部细节可以参考第四部分中对CanvasRenderingContext2D对象解释的内容,也可以参考其中对ImageData对象的详细解释。
例21-10:使用ImageData实现动态模糊
//将矩形区域的像素向右进行涂抹,
//来产生动态模糊效果,就好像物体正在从右到左移动
//n必须要大于或等于2,该值越大,涂抹区域就越大
//矩形是在默认坐标系中指定的
function smear(c,n,x,y,w,h){//获取表示矩形区域内像素的ImageData对象来实现涂抹效果
var pixels=c.getImageData(x,y,w,h);//就地实现涂抹效果并且只需要ImageData对象数据
//一些图片处理算法要求额外的ImageData对象来存储变换后的像素值
//如果需要输出缓冲区,可以以如下方式创建一个新的同样尺寸的ImageData对象
//var output_pixels=c.createImageData(pixels);
//这些尺寸可能和w和h之类的参数不同:有可能是每个CSS像素要表示多个设备像素
var width=pixels.width,height=pixels.height;//data变量包含所有原始的像素信息:从左到右,从上到下
//每个像素按照R、G、B、A的顺序共占据4个字节
var data=pixels.data;//每一行第一个像素之后的像素都通过将其色值替换成
//其色素值的1/n+原色素值的m/n
var m=n-1;
for(var row=0;row<height;row++){//循环每一行
var i=rowwidth4+4;//每行第二个元素的偏移量
for(var col=1;col<width;col++,i+=4){//循环每一列
data[i]=(data[i]+data[i-4]*m)/n;//像素中红色分量
data[i+1]=(data[i+1]+data[i-3]*m)/n;//绿色
data[i+2]=(data[i+2]+data[i-2]*m)/n;//蓝色
data[i+3]=(data[i+3]+data[i-1]*m)/n;//Alpha分量
}
}
//现在将涂抹过的图片数据复制回画布相同的位置
c.putImageData(pixels,x,y);
}
要注意的是,getImageData()方法和toDataURL()方法一样,同样收同源安全策略的限制:它对于绘制的图片(通过drawImage()方法直接绘制或者通过CanvasPattern间接绘制)和画布所在文档不属于同源的画布是无效的。
21.4.15 命中检测
isPointInPath()方法确定一个指定的点是否落在(或者在边界上)当前路径中,如果该方法返回true则表示落在当前路径中,反之则返回false。传递给该方法的点是在默认坐标系中的而不是在变换过的坐标系中。这对于该方法用于命中检测(hit detection)是很有帮助的:检测鼠标单击事件是否发生在特定的形状上。
但是,不能将MouseEvent对象的clientX字段和clientY字段直接传递给isPointInPath()方法。首先,必须要将鼠标事件的坐标转换成相应的画布元素,而不是Window对象。其次,如果画布在屏幕上显示的尺寸和实际尺寸不同,鼠标事件坐标必须要进行适当的缩放。例21-11显示了一个工具函数,用它可以检测一个给定的MouseEvent是否发生在当前路径上。
例21-11:检测一个鼠标事件是否发生在当前路径上
//如果鼠标事件发生指定的CanvasRenderingContext2D对象的当前路径上则返回true
function hitpath(context,event){//从<canvas>对象中获取<canvas>元素
var canvas=context.canvas;//获取画布尺寸和位置
var bb=canvas.getBoundingClientRect();//将鼠标事件坐标通过转换和缩放变换成画布坐标
var x=(event.clientX-bb.left)*(canvas.width/bb.width);
var y=(event.clientY-bb.top)*(canvas.height/bb.height);//用这些变换后的坐标来调用isPointInPath()方法
return context.isPointInPath(x,y);
}
可能还会使用如下所示的hitpath()函数作为事件处理程序:
canvas.onclick=function(event){
if(hitpath(this.getContext("2d"),event){
alert("Hit!");//单击在当前路径上
}
};
除了进行基于路径的命中检测之外,还可以使用getImageData()方法来检测鼠标点下的像素是否已经绘制过了。如果返回的像素(单个或多个)是完全透明的,则表示该像素上没有绘制任何内容,并且鼠标事件点空了。例21-12展示了如何做此类命中检测。
例21-12:检测鼠标事件触发点的元素是否绘制过了
//如果指定的鼠标事件点下的像素不是透明的则返回true
function hitpaint(context,event){//通过转换和缩放将鼠标事件坐标转换成画布坐标
var canvas=context.canvas;
var bb=canvas.getBoundingClientRect();
var x=(event.clientX-bb.left)*(canvas.width/bb.width);
var y=(event.clientY-bb.top)*(canvas.height/bb.height);//获取像素(或者多个设备像素映射到一个CSS像素的像素)
var pixels=c.getImageData(x,y,1,1);//如果任何像素的alpha值非0,则返回true(命中)
for(var i=3;i<pixels.data.length;i+=4){
if(pixels.data[i]!==0)return true;
}
//否则,表示不命中
return false;
}
21.4.16 画布例子:迷你图
本章将以一个实际的绘制迷你图的例子结束。迷你图(sparkline)是指用于显示少量数据的图形,通常会和嵌入在文本流中,如下所示:。“迷你图”这个词是由作者Edward Tufte杜撰的,他将该词用于描述“内嵌在文字、数字、图片中的小且高分辨率的图形”。迷你图是数据密集、设计简单、单词大小的图形。(要了解更多关于迷你图的知识可以阅读Tufte的书:Beautiful Evidence[Graphics Press]。)
例21-13展示了在Web页面中用于实现迷你图的JavaScript代码,相对而言,该JavaScript是一个比较简单的常见JavaScript代码模块。代码中的注释解释了它的原理。要注意是的,它使用了例13-5中的onLoad()函数。
例21-13:使用<canvas>元素绘制迷你图
/*
*找到所有有"sparkline"CSS类的元素,将它们的内容解析成一系列数字
*最后替换成图形化的表示方式
*
*将使用标记将迷你图定义成如下形式:
*<span class="sparkline">3 5 7 6 6 9 11 15</span>
*
*使用CSS对迷你图进行样式设置,如下所示:
*.sparkline{background-color:#ddd;color:red;}
*
*-迷你图的颜色是根据CSS的color属性计算出来的
*-迷你图是透明的,因此可以显示正常的背景色
*-如果设置了data-height属性,迷你图的高度则由该属性指定,
*如果没有设置,则根据font-size属性计算得出
*-如果设置了data-width属性,迷你图的宽度则由该属性指定
*如果没有设置该属性,而设置了data-dx属性,则迷你图的宽度等于数据点的个数乘以
*data-dx的值;否则,图表的宽度等于数据点的个数乘以图表的高度再除以6
*-如果设置了data-ymin属性和data-ymax属性,则最小值和最大值由这两个属性值指定
*否则,最小值和最大值等于数据的最小值和最大值
*/
onLoad(function(){//当文档第一次载入时
//找到所有有"sparkline"类的元素
var elts=document.getElementsByClassName("sparkline");
main:for(var e=0;e<elts.length;e++){//循环每个元素
var elt=elts[e];//获取元素内容并转换成一个包含数字的数组
//如果转换失败,则跳过该元素
var content=elt.textContent||elt.innerText;//元素内容
var content=content.replace(/^\s+|\s+$/g,"");//去除空格
var text=content.replace(/#.*$/gm,"");//去除注释
text=text.replace(/[\n\r\t\v\f]/g,"");//将\n等转换成空格
var data=text.split(/\s+|\s,\s/);//以空格或者逗号进行分隔
for(var i=0;i<data.length;i++){//循环每个数据块
data[i]=Number(data[i]);//转换成一个数字
if(isNaN(data[i]))continue main;//转换失败则中止
}
//现在根据数据和元素的data-属性以及元素的计算样式,来计算
//迷你图的颜色、宽度、高度和Y轴的范围
var style=getComputedStyle(elt,null);
var color=style.color;
var height=parseInt(elt.getAttribute("data-height"))||
parseInt(style.fontSize)||20;
var width=parseInt(elt.getAttribute("data-width"))||
data.length*(parseInt(elt.getAttribute("data-dx"))||height/6);
var ymin=parseInt(elt.getAttribute("data-ymin"))||
Math.min.apply(Math,data);
var ymax=parseInt(elt.getAttribute("data-ymax"))||
Math.max.apply(Math,data);
if(ymin>=ymax)ymax=ymin+1;//创建一个画布元素
var canvas=document.createElement("canvas");
canvas.width=width;//设置画布尺寸
canvas.height=height;
canvas.title=content;//将元素内容作为工具提示
elt.innerHTML="";//将现有的元素内容抹除
elt.appendChild(canvas);//将该元素插入到画布中
//现在绘制点(i,data[i]),转换成画布坐标
var context=canvas.getContext('2d');
for(var i=0;i<data.length;i++){//循环每个数据点
var x=width*i/data.length;//缩放i倍
var y=(ymax-data[i])*height/(ymax-ymin);//缩放data[i]
context.lineTo(x,y);//首先调用lineTo()方法而不是moveTo()方法
}
context.strokeStyle=color;//设置迷你图的颜色
context.stroke();//并将它绘制出来
}
});
[1]浅绿色、黑色、蓝色、紫红色、灰色、绿色、石灰色、褐红色、藏青色、橄榄色、紫色、红色、银色、深青色、白色和黄色。
[2]夹角越小,斜接部分越长。
[3]在撰写本书时,Google的Chrome浏览器(版本5)把这弄错了并变换了阴影的偏移量。