近来canvas用的比较多,顺手整理了一下,附带在线示例,水平有限,如有错误,欢迎comment😁
内容 |
---|
canvas绘制复杂图形 |
canvas画布理解 |
高dpi屏绘制模糊问题 |
canvas实现图片放大镜 |
canvas绘制复杂图形
这里常用的canvas api有moveTo
, lineTo
, arc
等,这些就不细说了,怎么使用可看下面的例子。
绘制时要十分注意画笔位置,及时将其moveTo到指定位置,如果要进行选择性填充,可利用非零环绕原则或者奇偶环绕原则。
非零环绕 (默认)
ctx.fill('nonzero')
非零环绕有方向之分,从一块区域向外画一条射线,每经过一条顺时针的线则+1, 逆时针的线则-1,一直到最外面,如果总和等于0则不填充,如图所示
奇偶环绕
ctx.fill('evenodd')
奇偶环绕无方向之分,从一块区域向外画一条射线,一直到最外面,经过的线的个数,如果是奇数,则填充;如果是偶数,则不填充。
复杂图形绘制示例
要绘制的图形如下,需要对其描边和涂色
利用ctx.arc
绘制该图像的基本轮廓,如图
根据涂色要求,画圆时需要注意绘制的方向,利用非零环绕原则,其中最里面的圆使用逆时针,其他五个圆使用顺时针(或者最里面的圆使用顺时针,其他五个圆使用逆时针),即为目标效果。➡️ stackblitz.com/edit/js-ngu…
代码如下
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
ctx.beginPath();
ctx.strokeStyle="red"
ctx.fillStyle="blue"
ctx.moveTo(55, 40);
ctx.arc(40, 40, 15, 0, 2*Math.PI, false);
ctx.moveTo(70, 40);
ctx.arc(40, 40, 30, 0, 2*Math.PI, true);
ctx.moveTo(45, 10);
ctx.arc(40, 10, 5, 0, 2*Math.PI, true);
ctx.moveTo(75, 40)
ctx.arc(70, 40, 5, 0, 2*Math.PI, true);
ctx.moveTo(15, 40)
ctx.arc(10, 40, 5, 0, 2*Math.PI, true);
ctx.moveTo(45, 70)
ctx.arc(40, 70, 5, 0, 2*Math.PI, true);
//这里我们将路径都设置好,最后才一起描边和填充。注意先描边再填充,否则线会画在上方
ctx.stroke();
ctx.fill();
注意⚠️
-
使用beginPath会清空之前的路径, 这样使用fill或stroke就不会重复绘制已存在的内容,否则你会发现同一个轨迹每次stroke,就会越描越深
-
closePath用于连接起点和终点
canvas画布理解:canvas逻辑大小,物理大小,坐标大小
下面是个人理解
逻辑大小
通过canvas.width, canvas.height设置
物理大小
通过canvas.style.width, canvas.style.height 设置,即浏览器中展示出来的大小
1. 当逻辑大小范围>物理大小范围 时相当于对canvas画布进行了压缩,故更加清晰
2. 当逻辑大小范围<物理大小范围 时相当于对canvas画布进行了拉伸,故会变得模糊
坐标系大小
通过scale等变换进行设置,scale放大时单位长度表示的范围更大,scale缩小时单位长度表示的范围更小;
一开始canvas的坐标和画布逻辑大小是相适应的,如图假设蓝色为画布,坐标位置假设放到了中间
当改变canvas逻辑大小,但不缩放坐标时,单位长度表示的范围不变
当改变canvas逻辑大小,同等缩放坐标时,又变成吻合的了,但单位长度表示的范围变大了
注意对坐标系的变换,画布清空后将失效!!!
高倍屏模糊
原因
对于高dpi的设备,相同的一块视觉区域里有更多的像素,可以理解成本来canvas的逻辑大小和物理大小是一样大的,但高dpi设备下因为像素更多,让canvas的物理大小表示的范围更大了,所以当逻辑大小范围<物理大小范围 时相当于对canvas画布进行了拉伸,故会变得模糊
方案
因此,要想在高倍屏幕下绘制清晰,关键是知道当前屏幕的设备像素比ratio,然后将 canvas 的逻辑大小放大ratio倍,然后 canvas 的物理大小(area)不变,这样在浏览器看来有area*ratio
个像素(物理大小),然后canvas逻辑大小也有area*ratio
个像素,刚好吻合。
代码
canvasDPI函数获取到屏幕像素比ratio后,对canvas的逻辑大小进行了放缩ratio倍,同时将坐标系大小也放缩了ratio倍,这样我们在之后的绘图后就可以准确定位在画布上的绘制位置,而无需考虑是否再对坐标进行放缩
➡️ stackblitz.com/edit/js-gfk…
/**
* 处理canvas在高倍屏下模糊的问题
* @return radio 像素比
* const ratio = canvasDPI(canvas)
*/
function canvasDPI(canvas, customWidth, customHeight) {
if (!canvas) return 1;
const width = customWidth ||
canvas.width ||
canvas.clientWidth;
const height = customHeight ||
canvas.height ||
canvas.clientHeight;
const context = canvas.getContext('2d')
const deviceRatio = window.devicePixelRatio || 1;
const bsRatio = context.webkitBackingStorePixelRatio ||
context.mozBackingStorePixelRatio ||
context.msBackingStorePixelRatio ||
context.oBackingStorePixelRatio ||
context.backingStorePixelRatio || 1;
const ratio = deviceRatio / bsRatio;
if (deviceRatio !== bsRatio) {
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
context.scale(ratio, ratio);
}
return ratio;
}
canvasDPI(canvas);
const ctx = canvas.getContext('2d');
const img = new Image();
img.crossOrigin = "anonymous";
img.src="https://ss0.bdstatic.com/70cFuHSh_Q1YnxGkpoWK1HF6hhy/it/u=1100304840,1265248222&fm=26&gp=0.jpg";
img.onload = function() {
ctx.drawImage(img, 0, 0, 200, 100)
}
如上面两张图是高倍屏处理前后,肉眼可见的差异
canvas实现图片放大镜
完整代码和效果见➡️:stackblitz.com/edit/js-seb…
整体思路就是用一个canvas绘制完整图片,对鼠标事件进行监听来计算所在canvas坐标里的位置,将该点指定范围的图片数据提取出来放入另一个canvas来进行放大展示。
完整图片绘制到canvas,老规矩要进行canvas的高DPI适配,避免模糊
const canvasBg = document.getElementById('bg');
const ratio = canvasDPI(canvasBg, 200, 100);
const ctxBg = canvasBg.getContext('2d');
const img = new Image();
img.crossOrigin = "anonymous";//实现在画布中使用跨域 <img> 元素的图像
img.src="https://ss0.bdstatic.com/70cFuHSh_Q1YnxGkpoWK1HF6hhy/it/u=1100304840,1265248222&fm=26&gp=0.jpg";
img.onload = function() {
ctxBg.drawImage(img, 0, 0, 200, 100)
}
监听鼠标事件,将鼠标位置转换成canvas画布里的位置
let pickX, pickY, ifEmit, radius=30, rect=canvasBg.getBoundingClientRect();
function attachListener(canvas) {
canvas.addEventListener('mousedown', onMousedown);
canvas.addEventListener('touchstart', onMousedown);
document.addEventListener('mousemove',
onMousemove);
document.addEventListener('touchmove',
onMousemove);
document.addEventListener('mouseup',
onMouseup);
document.addEventListener('touchend',
onMouseup);
}
function onMousedown(e) {
ifEmit = +new Date();
xy2canvas(e);
startLoupe();
}
function onMousemove(e) {
ifEmit && xy2canvas(e);
}
function onMouseup(e) {
pickX = null;
pickY = null;
ifEmit = null;
stopLoupe();
}
function xy2canvas(e) {
const {x, y} = getEventXY(e);
pickX = x - rect.left;
pickY = y - rect.top;
}
function getEventXY(e) {
return {
x: e.clientX || (e.touches && e.touches[0] && e.touches[0].clientX) || (e.changedTouches && e.changedTouches[0] && e.changedTouches[0].clientX),
y: e.clientY || (e.touches && e.touches[0] && e.touches[0].clientY) || (e.changedTouches && e.changedTouches[0] && e.changedTouches[0].clientX)
}
}
- 这里在canvas和document上都监听了事件,是为了当鼠标移出画布后仍可以继续更新位置,当又移回画布后继续绘制,如图,同时为了让触摸事件是从画布中进行开始的doucument不监听了start事件,另外使用ifEmit控制完整的一次触摸顺序是 画布里start-->move-->up
- pc端mouse类事件
clientX 根据浏览器窗口的大小
offsetX 类似clientX,但包含浏览器边框,如工具栏,导航栏
screenX 根据屏幕大小
pageX 根据页面大小
- 移动端touch类事件
touches: 当前屏幕上所有触摸点的列表;
targetTouches: 当前元素上所有触摸点的列表;
changedTouches: 引发事件的触摸点的列表
区别辨析
- 一个手指触摸屏幕时,三个属性值相同,都只有一个数据
- 第二根手指触摸屏幕,touches有两个数据,每个手指一个;targetTouches只有当第二根手指放的位置和第一个手指是同一个元素时才会有两个数据; changedTouches只有第二根手指的数据,因为第二根手指是导致事件的原因
- 同时放下两根手指时,三个属性值相同,都有两个数据
- 手指滑动时,三个值都会发生变化
- 一个手指离开屏幕,touches和targetTouches中对应的元素会同时移除,而changedTouches仍然会存在元素。
- 手指都离开屏幕之后,touches和targetTouches中将不会再有值,changedTouches还会有一个值,此值为最后一个离开屏幕的手指的接触点。
- 综上,在touchstart和touchmove可以用touches 或targetTouches;在touchend使用changedTouches
我在getEventXY函数中对两端的触摸事件进行了兼容
提取指定位置的图片局部数据并绘制到放大镜canvas中
const canvasLoupe = document.getElementById('loupe');
const ctxLoupe = canvasLoupe.getContext('2d');
let timeId, colorInfo;
function startLoupe() {
canvasLoupe.style.width = `${2*radius}px`;
canvasLoupe.style.height = `${2*radius}px`;
timeId = setInterval(()=>{
if(typeof pickX === 'number') {
colorInfo = getImageData(pickX, pickY)
drawLoupe(colorInfo);
}
}, 60)
}
function stopLoupe() {
clearInterval(timeId);
colorInfo = null;
canvasLoupe.style.width = 0;
canvasLoupe.style.height = 0;
}
function getImageData(x, y) {
const imageData = ctxBg.getImageData(x*ratio-radius, y*ratio-radius, 2*radius, 2*radius);
return {
x,
y,
data: imageData.data,
width: imageData.width,
height: imageData.height
}
}
function drawLoupe(colorInfo) {
if(!colorInfo) return;
const {x, y, width, height, data} = colorInfo;
canvasLoupe.width = width;
canvasLoupe.height= height;
const tmpCanvas = document.createElement('canvas');
tmpCanvas.width = width;
tmpCanvas.height = height;
const tmpCtx = tmpCanvas.getContext('2d');
const imageData = tmpCtx.createImageData(width, height);
imageData.data.set(data);
tmpCtx.putImageData(imageData, 0, 0);
ctxLoupe.scale(zoomScale, zoomScale);
ctxLoupe.drawImage(tmpCanvas,0,0,width, height,0,0, width, height);
canvasLoupe.style.left = `${x-width/2}px`;
canvasLoupe.style.top = `${y-height/2}px`;
}
-
drawLoupe中先将图片数据构建成ImageData对象,在将其放到临时的canvas,之后利用drawImage绘制到放大镜canvas中
-
这里说下绘制频率和时机的问题,一般情况下是start事件开始绘制,然后move事件触发几次就绘制几次,up事件时停止绘制。我这里将绘制操作从高频触摸事件剥离出来放入自定义的setInterval中触发,为的是降低频率提高性能。
实现方案就是start事件时开始执行startLoupe,然后开始setInterval循环,每60ms一次(和屏幕刷新率保持一致),然后move事件的作用就是更新pickX和pickY,最后在up事件时执行stopLoupe停止setInterval.
测试了一下,目前该方案三次move事件才会有一次startLoupe, 放大镜实时效果也不会有卡顿