前言
早就想写这篇博客。一个原因是其中某些内容在某度的面试中遇到,比如第二部分;二是一些需求在实际工程中遇到了。
一、基本概念
Canvas:HTML5新增的DOM,提供了像素级图形编程接口。支持path,不支持鼠标响应,不能绘制矢量图,依赖分辨率,文本渲染能力弱,刷新能力强。适用于图像密集型编程,如游戏。
SVG:一种使用XML描述2D图像的语言。因此SVG中所有元素都是DOM。支持path,支持鼠标事件,能绘制矢量图,不依赖分辨率,文本渲染能力强,刷新能力弱。适用于大区域渲染程序,比如地图。
二、性能对比及应用场景
详情见这篇文章:http://msdn.microsoft.com/zh-cn/library/gg193983,这里给出一些摘要。
选择SVG:
l 用于查看和打印的高保真文档,如建筑图、工程图、电子图、航空图、地理图、组织图、生物图等。
l 静态图像,比如带有某些特效的图片,用SVG会节省图片的存储空间。
选择Canvas:
l 高性能图形,如光线跟踪器、3D引擎渲染器。
l 复杂场景,实时动画等。
l 图像处理,涉及到像素操作。
三、应用中的问题
既然Canvas和SVG各有特点,我们只需要在特定场景选择特定技术就行了。但其中有一些问题。比如我想做一个CAD online,就是在Web上做一个工程制图工具,能编辑CAD图纸,用哪种技术好?首先想到的肯定是SVG,因为编辑器肯定要在工作区选择某一条线、某一个元素,这需要鼠标事件。并且,SVG适合高保真图纸的显示,又能保存成矢量图。
但实际做起来,就不太容易了。因为SVG是DOM,如果一张图纸里有几万条线,恐怕打开图纸,成功渲染出来都要几分钟,更别说编辑、来回拖动图纸了。可用Canvas绘制图纸,先不说怎样高保真绘制,这些可以通过引入图形算法解决,单响应鼠标事件这一项就不好实现,似乎不可能从像素中识别出一条完整的线。
真的不行么?接下来我们就来解决这个问题,并做一个简单的DEMO,让Canvas看起来像极了SVG,能响应鼠标事件。
四、Canvas的鼠标接口
Canvas其实提供了一个鼠标接口,仔细翻API文档,你就会发现isPointInPath()方法,这是我们唯一可以利用的。该方法的作用是判断Canvas中的某个点,在不在某个路径中。输入参数两个,x值和y值,笛卡尔坐标,返回值boolean:
- <span style="font-size:14px;"> var ctx =document.getElementById(‘container’).getContext(‘2d’);
- var isin = ctx.isPointPath(0, 0);</span>
需要强调的是,前面提到“在不在某个路径中”,指的是哪个路径?Canvas在绘制时,都是以路径为基本单位。路径以ctx.beginPath();开始,那么以什么结束呢?很多人说closePath()结束,这是大错特错的说法。closePath() 的意思是闭合路径,而不是结束路径,如果是endPath 就对了。其实,路径,在绘制时宣布结束,即ctx.strock()或ctx.fill()。只要碰到这两个方法的任何一个,都算一个路径绘制结束。接下来剩下的问题就是,什么时候使用isPointInPath(),就是在路径的开始后,绘制前。在这个阶段可以做很多事:设置线条样式、设置填充样式、在路径中绘画、判断某个点是否在路径中。
比如下面这个例子,我们画两条折线,这两条折线在不同路径上,并判断某个点是否在某个路径上,如果是,讲那个路径绘制成红色,否则绘制成黑色。代码版本1:
- <span style="font-size:14px;"><!DOCTYPE html>
- <html>
- <head>
- <meta charset="UTF-8"/>
- <title>canvas2SVG</title>
- <style>
- #container {
- border: 1px solid #000;
- position: 0px;
- left: 0px;
- top: 0px;
- }
- </style>
- </head>
- <bodyonloadbodyonload="init()">
- <canvas width="800px"height="400" id="container"></canvas>
- <scripttypescripttype="text/javascript">
- function init() {
- var ctx = document.getElementById('container').getContext('2d');
- var point = [300, 100];
- //路径1
- ctx.beginPath();
- ctx.moveTo(0, 0);
- ctx.lineTo(0, 100);
- ctx.lineTo(100, 100);
- ctx.lineTo(100, 0);
- ctx.lineTo(0, 0);
- if (ctx.isPointInPath(point[0], point[1])) {
- ctx.fillStyle = '#f00';
- } else {
- ctx.fillStyle = '#000';
- }
- ctx.fill();
- //路径2
- ctx.beginPath();
- ctx.moveTo(400, 0);
- ctx.lineTo(400, 200);
- ctx.lineTo(200, 200);
- ctx.lineTo(400, 0);
- if (ctx.isPointInPath(point[0], point[1])) {
- ctx.fillStyle = '#f00';
- } else {
- ctx.fillStyle = '#000';
- }
- ctx.fill();}
- </script>
- </body>
- </html></span>
五、鼠标响应
有了这个神器,我们就可以做鼠标响应了。每绘制一个路径,只要判断鼠标是不是在它上面,从而获取鼠标下的路径。那么,绘制结束了怎么办?我们把绘制方法挂到mousemove事件上,只要移动就重绘。Canvas绘制效率很高,在1080p分辨率下,根本不用考虑绘制效率问题。代码版本2如下:
- <span style="font-size:14px;">functioninit() {
- var width = 800;
- var height = 400;
- var container =document.getElementById('container');
- var ctx = container.getContext('2d');
- var func = [line1, line2];
- container.width = width;
- container.height = height;
- document.getElementById('container').addEventListener('mousemove',render);
- render({clientX: -1, clientY: -1});
- function render(event) {
- ctx.clearRect(0, 0, width, height);
- for (var key in func) {
- ctx.beginPath();
- ctx.fillStyle = func[key](event.clientX,event.clientY);
- ctx.fill();
- }
- }
- function line1(x, y) {
- ctx.moveTo(0, 0);
- ctx.lineTo(0, 100);
- ctx.lineTo(100, 100);
- ctx.lineTo(100, 0);
- ctx.lineTo(0, 0);
- return ctx.isPointInPath(x, y) ? '#f00': '#000';
- }
- function line2(x, y) {
- ctx.moveTo(400, 0);
- ctx.lineTo(400, 200);
- ctx.lineTo(200, 200);
- ctx.lineTo(400, 0);
- return ctx.isPointInPath(x, y) ? '#f00': '#000';
- }
- }</span>
这段代码的效果是鼠标移动到哪个物体上,哪个物体变成红色,其他物体是黑色。
注意,在Canvas里面,路径都是实心的,哪怕不填充颜色,也是实心的。比如在上面代码中把的fillStyle改成strokeStyle,把fill()改成strock(),当鼠标移动到物体内部时,虽然鼠标不在边线上,物体也会变颜色。更确切的说法,Canvas绘制path时,如果path的最后一个点和第一个点不重合,路径不闭合,Canvas会自动闭合上。所以closePath()这个方法基本没用。
六、物体重叠
上面的例子物体不重叠,那么重叠了会是什么效果?在看下面的代码版本3:
- <span style="font-size:14px;">function init() {
- var width = 800;
- var height = 400;
- var container = document.getElementById('container');
- var ctx = container.getContext('2d');
- var geometry = [
- [
- [[10, 10], [10, 110], [110, 110],[10, 10]],
- [[110, 10], [110, 110], [210, 110],[110, 10]],
- [[210, 10], [210, 110], [310, 110],[210, 10]]
- ],[
- [[0, 50], [0, 150], [100, 150], [0,50]],
- [[100, 50], [100, 150], [200, 150],[100, 50]],
- [[200, 50], [200, 150], [300, 150],[200, 50]]
- ],[
- [[0, 100], [0, 200], [100, 200],[0, 100]],
- [[100, 100], [100, 200], [200,200], [100, 100]],
- [[200, 100], [200, 200], [300, 200],[200,100]]
- ]
- ];
- container.width = width;
- container.height = height;
- document.getElementById('container').addEventListener('mousemove',render);
- render({clientX: -1, clientY: -1});
- function render(event) {
- ctx.clearRect(0, 0, width, height);
- for (var n = 0; n < geometry.length;n++) {
- ctx.beginPath();
- ctx.strokeStyle =draw(event.clientX, event.clientY, geometry[n]);
- ctx.stroke();
- }
- }
- function draw(x, y, geo) {
- for (var i = 0; i < geo.length; i++) {
- var face = geo[i];
- for (var p = 0; p < face.length;p++) {
- if (p == 0) {
- ctx.moveTo(face[p][0],face[p][1]);
- } else {
- ctx.lineTo(face[p][0],face[p][1]);
- }
- }
- }
- return ctx.isPointInPath(x, y) ? '#f00' :'#000';
- }
- }</span>
版本3中,我们绘制了三个路径,每个路径由三个三角形组成。除了绘图方法,其他代码同版本2相同,于是出现了这种情况:
当鼠标移动到黑色箭头所指的区域,三个物体全部变红,说明这个位置,处在三个物体中。从代码里就能看出来,这个问题是我们实现的逻辑造成的。
如果想单独一个物体,比如最后绘制,只要增加一个数组就行了。用这个数组保存所有处在鼠标下的物体,然后逆序,数组第一个元素就是我们想得到的物体,最后再重绘所有物体就成了。代码版本4:
- <span style="font-size:14px;"> function render(event) {
- ctx.clearRect(0, 0, width, height);
- var hover = [];
- for (var n = 0; n < geometry.length;n++) {
- ctx.beginPath();
- ctx.strokeStyle =draw(event.clientX, event.clientY, geometry[n]);
- if (ctx.strokeStyle == '#ff0000') {
- hover.push(n);
- }
- ctx.stroke();
- }
- hover.reverse();
- if (hover.length > 0) {
- for (var n = 0; n <geometry.length; n++) {
- ctx.beginPath();
- draw(event.clientX,event.clientY, geometry[n]);
- ctx.strokeStyle = n == hover[0]? '#f00' : '#000'
- ctx.stroke();
- }
- }
- }</span>
这里我们重写了render方法,效果如下。
是不是看着很眼熟?没错,这就是浏览器冒泡机制。我们一步一步做到这里,原理一点儿都不复杂。
七、物体重叠的解决
做到这里,虽然看到了曙光,但我们的DEMO还是没有实用价值。因为物体还是实心的,我们真正想要的,是空心物体。如果我们把lineTo方法自己实现,用一个path模拟一条线,这个问题也就迎刃而解了。
lineTo涉及到起点、终点,我们可以用四边形把这两个点圈起来,如果想要好看一些,四边形可以做出圆角、阴影、虚线等等效果。这些都是计算机图形学里的知识,我们只给出下面图中的四边形定点如何计算。
当前画笔位置(x0,y0),原lineTo的参数(x1, y1),则A、B、E、F 坐标计算过程如下(理论证明略,初中几何):
- <span style="font-size:14px;"> d = Math.sqrt((x0 - x1) * (x0 - x1) + (y0 -y1) * (y0 - y1));
- r = 1;//圆的半径
- sina = (x1 - x0) / d;
- cosa = (y1 - y0) / d;
- ax = x0 + r * cosa;
- ay = y0 - r * sina;
- bx = x0 + r * cosa + x1 - x0;
- by = y0 - r * sina + y1 - y0;
- ex = x0 - r * cosa + x1 - x0;
- ey = y0 + r * sina + y1 - y0;
- fx = x0 - r * cosa;
- fy = y0 + r * sina;
- dx = x0 + r * cosa;
- dy = y0 - r * sina;</span>
接下来就是重写我们的绘图方法,代码版本5:
- <span style="font-size:14px;"><!DOCTYPEhtml>
- <html>
- <head>
- <metacharsetmetacharset="UTF-8" />
- <title>canvas2SVG</title>
- <style>
- #container{
- border: 1px solid #000;
- position: absolute;
- left: 0px;
- top: 0px;
- }
- </style>
- </head>
- <bodyonloadbodyonload="init()">
- <canvasidcanvasid="container"></canvas>
- <scripttypescripttype="text/javascript">
- functioninit() {
- var width = 800;
- var height = 400;
- var container =document.getElementById('container');
- var ctx = container.getContext('2d');
- var geometry = [
- [
- [[10, 10], [10, 110], [110, 110],[10, 10]],
- [[110, 10], [110, 110], [210, 110],[110, 10]],
- [[210, 10], [210, 110], [310, 110],[210, 10]]
- ],[
- [[0, 50], [0, 150], [100, 150], [0,50]],
- [[100, 50], [100, 150], [200, 150],[100, 50]],
- [[200, 50], [200, 150], [300, 150],[200, 50]]
- ],[
- [[0, 100], [0, 200], [100, 200],[0, 100]],
- [[100, 100], [100, 200], [200,200], [100, 100]],
- [[200, 100], [200, 200], [300,200], [200,100]]
- ]
- ];
- container.width = width;
- container.height = height;
- document.getElementById('container').addEventListener('mousemove',render);
- render({clientX: -1, clientY: -1});
- function render(event) {
- ctx.clearRect(0, 0, width, height);
- var hover = [];
- for (var n = 0; n < geometry.length;n++) {
- ctx.beginPath();
- ctx.strokeStyle = draw(event.clientX,event.clientY, geometry[n]);
- if (ctx.strokeStyle == '#ff0000') {
- hover.push(n);
- }
- ctx.stroke();
- }
- hover.reverse();
- if (hover.length > 0) {
- for (var n = 0; n <geometry.length; n++) {
- ctx.beginPath();
- draw(event.clientX,event.clientY, geometry[n]);
- ctx.strokeStyle = n == hover[0]? '#f00' : '#000'
- ctx.stroke();
- }
- }
- }
- function draw(x, y, geo) {
- for (var i = 0; i < geo.length; i++) {
- var face = geo[i];
- var oldx = null;
- var oldy = null;
- for (var p = 0; p < face.length;p++) {
- if (p == 0) {
- oldx = face[p][0];
- oldy = face[p][1];
- } else {
- line(oldx, oldy, face[p][0],face[p][1]);
- oldx = face[p][0];
- oldy = face[p][1];
- }
- }
- }
- return ctx.isPointInPath(x, y) ? '#f00' :'#000';
- }
- function line(x0, y0, x1, y1) {
- var d = Math.sqrt((x0 - x1) * (x0 - x1)+ (y0 - y1) * (y0 - y1));
- var r = 0.5;
- var sina = (x1 - x0) / d;
- var cosa = (y1 - y0) / d;
- ctx.moveTo(x0 + r * cosa, y0 - r *sina);
- ctx.lineTo(x0 + r * cosa + x1 - x0, y0- r * sina + y1 - y0);
- ctx.lineTo(x0 - r * cosa + x1 - x0, y0+ r * sina + y1 - y0);
- ctx.lineTo(x0 - r * cosa, y0 + r * sina);
- ctx.lineTo(x0 + r * cosa, y0 - r *sina);
- }
- }
- </script>
- </body>
- </html></span>
效果图如下:
至此,我们想要的功能都已经实现了。空心物体,响应鼠标事件。那实心物体怎么办?实心物体,使用版本1中的方法,空心物体使用版本6中的方法画。