Canvas鼠标滚轮缩放以及画布拖动(图文并茂版)
创始人
2024-05-25 14:25:49
0次

Canvas鼠标滚轮缩放以及画布拖动

本文会带大家认识Canvas中常用的坐标变换方法 translate 和 scale,并结合这两个方法,实现鼠标滚轮缩放以及画布拖动功能。

Canvas的坐标变换

Canvas 绘图的缩放以及画布拖动主要通过 CanvasRenderingContext2D 提供的 translate 和 scale 两个方法实现的,先来认识下这两个方法。

translate 方法

语法:

translate(x, y)

translate 的用法记住一句话:

translate 方法重新映射画布上的(0, 0)位置。

说白了就是把画布的原点移动到了 translate 方法指定的坐标,之后所有图形的绘制都会以该坐标进行参照。

举个例子:

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 600;
canvas.height = 400;ctx.fillStyle = 'red';
ctx.fillRect(50, 50, 50, 50);ctx.translate(50, 50);ctx.fillStyle = 'green';
ctx.fillRect(50, 50, 50, 50);

开始的时候,Canvas 容器原点和绘图原点重合,绘制一个背景色为红色,原点坐标(50, 50),长宽各为 50 的矩形,接着调用 translate 方法将绘图原点沿水平和纵向各偏移50,再绘制一个背景色是绿色,原点坐标(50, 50),长宽各为 50 的矩形,示意图如下,其中灰色的背景为 Canvas 区域。

translate.png

需要注意的是,如果此时继续调用 translate 方法进行偏移操作,后续的偏移会基于原来偏移的基础上进行的。

ctx.fillStyle = 'red';
ctx.fillRect(50, 50, 50, 50);// 第一次坐标系偏移
ctx.translate(50, 50);ctx.fillStyle = 'green';
ctx.fillRect(50, 50, 50, 50);// 第二次坐标系偏移
ctx.translate(50, 50);ctx.fillStyle = 'blue';
ctx.fillRect(50, 50, 50, 50);

第二次translate.png

因此,如果涉及到多次调用 translate 方法进行坐标变换,很容易将坐标系搞混乱,所以,一般在translate 之前会调用 save 方法先保存下绘图的状态,再调用 translate 后,绘制完图形后,调用 restore 方法恢复之前的上下文,对坐标系进行还原,这样不容易搞乱坐标系。

save方法通过将当前状态压入堆栈来保存画布的整个状态。

保存到堆栈上的图形状态包括:

  • 当前转换矩阵。
  • 当前裁剪区域。
  • 当前的破折号列表。
  • 包含的属性:strokeStyle、ill Style、lobalAlpha、linewidth、lineCap、lineJoin、miterLimit、lineDashOffset、shadowOffsetX、shadowOffsetY、shadowBlur、shadowColor、global alCompositeOperation、Font、extAlign、extBaseline、Direction、ImageSmoothingEnabled。

restore 方法通过弹出绘制状态堆栈中的顶部条目来恢复最近保存的画布状态。

ctx.fillStyle = 'red';
ctx.fillRect(50, 50, 50, 50);// 保存绘图上下文
ctx.save()ctx.translate(50, 50);
ctx.fillStyle = 'green';
ctx.fillRect(50, 50, 50, 50);// 绘制完成后恢复上下文
ctx.restore()ctx.fillStyle = 'blue';
ctx.fillRect(0, 0, 50, 50);

translate3.png

scale 方法

语法:

scale(x, y)

缩放 (scale) 就是将一个图形围绕中心点,然后将宽和高分别乘以一定的因子(sx,sy)

默认情况下,画布上的一个单位正好是一个像素。缩放变换会修改此行为。例如,如果比例因子为0.5,则单位大小为0.5像素;因此,形状的绘制大小为正常大小的一半。类似地,比例因子为2会增加单位大小,使一个单位变为两个像素;从而以正常大小的两倍绘制形状。

举个例子:

const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');ctx.scale(0.5,2);
ctx.fillStyle="blue";
ctx.fillRect(50,50,100,50);

调用 scale(0.5,2) 将画布水平方向缩小一倍,垂直方向放大一倍,绘制一个坐标原点 (50, 50),宽度 100,高度 50 的矩形。经过缩放变换后,距离原点的实际像素是横轴 25像素,纵轴 100 像素,宽度 50 像素,高度 100 像素。

scale.png

实现鼠标拖动画布

效果

创建Sence类

Sence类:

class Scene {constructor(id, options = {width: 600,height: 400}) {this.canvas = document.querySelector('#' + id)this.width = options.width;this.height = options.height;this.canvas.width = options.width;this.canvas.height = options.height;this.ctx = this.canvas.getContext('2d');}draw() {this.ctx.fillStyle = 'red';this.ctx.fillRect(50, 50, 50, 50);this.ctx.fillStyle = 'green';this.ctx.fillRect(150, 150, 50, 50);}clear() {this.canvas.width = this.width;}paint() {this.clear();this.draw();}
}let scene = new Scene('canvas');scene.draw();

在 Sence 类的构造函数中初始化 Canvas,得到 CanvasRenderingContext2D 对象,并设置 Canvas 的宽高属性,draw 方法里面绘制了两个矩形。

在进行下面的工作之前,我们先来了解下 Canvas 的事件机制。

通过 addEventListener 方法可以给 Canvas 绑定一个事件。

this.canvas.addEventListener('mousedown', (event) => {console.log(event.x)
});

事件的回调函数参数的 event 对象中可以获取鼠标点击 Canvas 时的坐标信息,event 对象中经常会用到的坐标有两个,一个是 event.x 和 event.y,另一个是 event.offsetX 和 event.offsetY,其中,event.x 和 event.y 获取的是鼠标点击时相对于屏幕的坐标,而 event.offsetX 和 event.offsetY 是相对于 Canvas 容器的坐标。

通过下面这张图可以清晰的看出两个坐标的区别,明白这一点对于我们后续的坐标变换非常重要。

事件坐标系.png

在构造函数中添加对 Canvas 的 mousedown 事件监听,记录点击鼠标时相对屏幕的位置 x 和 y。

class Scene {x = 0; // 记录鼠标点击Canvas时的横坐标y = 0; // 记录鼠标点击Canvas时的纵坐标constructor(id, options = {width: 600,height: 400}) {this.canvas.addEventListener('mousedown', this.onMousedown);}onMousedown(e) {if (e.button === 0) {// 点击了鼠标左键this.x = x;this.y = y;}}
}

画布拖动的整体思路就是利用前面介绍的 Canvas 的 translate 方法。画布的整体偏移量记录在 offset.x 和 offset.y,鼠标触发 mousedown 事件时,记录当前鼠标点击的位置相对于屏幕的坐标 x, 和 y,并且开始监听鼠标的 mousemove 和 mouseup 事件。鼠标触发 mousemove 事件时计算每次移动时整体累加的偏移量:

onMousemove(e) {this.offset.x = this.curOffset.x + (e.x - this.x);this.offset.y = this.curOffset.y + (e.y - this.y);this.paint();
}

其中 curOffset.x 和 curOffset.y 记录的是鼠标触发 mouseup 时保存的当前的偏移量,便于计算累加的偏移量。每次触发完鼠标 mousemove 事件后,重新进行图形绘制。

onMouseup() {this.curOffset.x = this.offset.x;this.curOffset.y = this.offset.y;window.removeEventListener('mousemove', this.onMousemove);window.removeEventListener('mouseup', this.onMouseup);
}

Sence 类完整代码如下:

class Scene {offset = { x: 0, y: 0 }; // 拖动偏移curOffset = { x: 0, y: 0 }; // 记录上一次的偏移量x = 0; // 记录鼠标点击Canvas时的横坐标y = 0; // 记录鼠标点击Canvas时的纵坐标constructor(id, options = {width: 600,height: 400}) {this.canvas = document.querySelector('#' + id);this.width = options.width;this.height = options.height;this.canvas.width = options.width;this.canvas.height = options.height;this.ctx = this.canvas.getContext('2d');this.onMousedown = this.onMousedown.bind(this);this.onMousemove = this.onMousemove.bind(this);this.onMouseup = this.onMouseup.bind(this);this.canvas.addEventListener('mousedown', this.onMousedown);}onMousedown(e) {if (e.button === 0) {// 鼠标左键this.x = e.x;this.y = e.ywindow.addEventListener('mousemove', this.onMousemove);window.addEventListener('mouseup', this.onMouseup);}}onMousemove(e) {this.offset.x = this.curOffset.x + (e.x - this.x);this.offset.y = this.curOffset.y + (e.y - this.y);this.paint();}onMouseup() {this.curOffset.x = this.offset.x;this.curOffset.y = this.offset.y;window.removeEventListener('mousemove', this.onMousemove);window.removeEventListener('mouseup', this.onMouseup);}draw() {this.ctx.fillStyle = 'red';this.ctx.fillRect(50, 50, 50, 50);this.ctx.fillStyle = 'green';this.ctx.fillRect(150, 150, 50, 50);}clear() {this.canvas.width = this.width;}paint() {this.clear();this.ctx.translate(this.offset.x, this.offset.y);this.draw();}
}

上述代码中有几点需要注意:

  1. 事件函数中的this指向问题

细心的同学可能注意到,在 Sence 类的构造函数里有这样几行代码:

constructor(id, options = {width: 600,height: 400}) {this.onMousedown = this.onMousedown.bind(this);this.onMousemove = this.onMousemove.bind(this);this.onMouseup = this.onMouseup.bind(this);}

为什么要使用 bind 函数给事件函数重新绑定this对象呢?

主要的原因在于一个事件有监听就会有移除。假设我们想要销毁 mousemove 事件怎么办呢?

可以调用 removeEventListener 方法进行事件监听的移除,比如上述代码会在 onMouseup 中移除对 mousemove 事件的监听:

onMouseup() {this.curOffset.x = this.offset.x;this.curOffset.y = this.offset.y;window.removeEventListener('mousemove', this.onMousemove);
}

如果不在构造函数中使用 bind 方法重新绑定 this 指向,此时的 this 指向的就是window,因为 this 指向的是调用 onMouseup 的对象,而 onMouseup 方法是被 window 上的 mouseup 事件调用的,但是实际上我们想要的this指向应该 Sence 实例。为了避免上述问题的出现,最好的解决办法就是在 Sence 类的构造函数中重新绑定 this 指向。

  1. 画布的清空问题

每次鼠标移动的时候会改变 Canvas 的 CanvasRenderingContext2D 偏移量,并重新进行图形的绘制,重新绘制的过程就是先将画布清空,然后设置画布的偏移量(调用 translate 方法),接着绘制图形。其中清空画布这里选择了重新设置Canvas的宽度,而不是调用 clearRect 方法,主要是因为clearRect 方法只在 Canvas 的渲染上下文没有进行过平移、缩放、旋转等变换时有效,如果 Canvas 的渲染上下文已经经过了变换,那么在使用 clearRect 清空画布前,需要先重置变换,否则 clearRect 将无法有效地清除整块画布。

实现鼠标滚轮缩放

效果

实现原理

鼠标滚轮的放大需要结合上面介绍的 Canvas 的 translate 和 scale 两个方法进行组合变换。

计算放大系数

监听鼠标滚轮的 mousewheel 事件,在事件的回调函数中通过 event.wheelDelta 值的变化来实时计算当前的缩放值,其中 event.wheelDelta > 0 表示放大,反之表示缩小,放大和缩小都有对应的阈值,超过阈值就禁止继续放大和缩小。

改造 Sence 类,添加 onMousewheel 事件:

onMousewheel(e) {if (e.wheelDelta > 0) {// 放大this.scale = parseFloat((this.scaleStep + this.scale).toFixed(2)); // 解决小数点运算丢失精度的问题if (this.scale > this.maxScale) {this.scale = this.maxScale;return;}} else {// 缩小this.scale = parseFloat((this.scale - this.scaleStep).toFixed(2)); // 解决小数点运算丢失精度的问题if (this.scale < this.minScale) {this.scale = this.minScale;return;}}this.preScale = this.scale;
}

其中,this.scale / this.preScale 计算出来的值就是放大系数,暂且记做 n。

在计算放大系数的时候,需要注意两个浮点型数值在计算不能直接相加,否则会出现丢失精度的问题。

缩放原理

在缩放的时候,会调用 scale(n, n) 方法,将坐标系放大 n 倍。假设鼠标滚轮停在 A 点进行放大操作,放大之后得到坐标 A’ 点。

图形放大1.png

可以看到,放大之后,A(x1, y1) 坐标变换到了 A'(x1, y1),A => A' 放大了 n 倍,因此得到 x1 = x * n,y1 = y1 * n。

这个时候就会存在一个问题,我们在 A 点进行放大,放大后得到的 A' 的位置应该是不变的,所以需要在放大之后需要调整 A’ 点的位置到 A 点。

这里我们采用的策略是在放大前先偏移一段距离,然后进行放大之后就可以保持 A 点和 A‘ 点的重合。

缩放原理图.png

鼠标停留在 A 点对蓝色矩形进行放大,放大系数为 n,蓝色矩形的起点左上角和坐标原点重合,宽度和高度分别是 x 和 y,因此,A点的坐标为 (x, y)。

前面我们说过,对 A 点进行放大后得到的 A’点应该和A点重合,这样就需要先把整个坐标系沿着x轴和y轴分别向左和向上偏移 offsetX 和 offsetY,偏移后得到的 A'点坐标记作 (x1, x2),因为 A 点是经过放大 n 倍后得到的 A' 点,所以得到以下距离关系:

x1 = x * n;
y1 = y * n

进一步就可以得到横纵坐标的偏移量 offsetX 和 offsetY 的绝对值:

offsetX = x*n-x;
offsetY =x*n - y;

因此,这需要将坐标系经过 translate(-offsetX, -offsetY) 之后,再 scale(n, n),就能确保 A 点 和 A‘ 点重合了。

明白了缩放的基本原理,下面就继续码代码吧😜。

onMousewheel(e) {e.preventDefault();this.mousePosition.x = e.offsetX; // 记录当前鼠标点击的横坐标this.mousePosition.y = e.offsetY; // 记录当前鼠标点击的纵坐标if (e.wheelDelta > 0) {// 放大this.scale = parseFloat((this.scaleStep + this.scale).toFixed(2)); // 解决小数点运算丢失精度的问题if (this.scale > this.maxScale) {this.scale = this.maxScale;return;}} else {// 缩小this.scale = parseFloat((this.scale - this.scaleStep).toFixed(2)); // 解决小数点运算丢失精度的问题if (this.scale < this.minScale) {this.scale = this.minScale;return;}}this.offset.x = this.mousePosition.x - ((this.mousePosition.x -   this.offset.x) * this.scale) / this.preScale;this.offset.y = this.mousePosition.y - ((this.mousePosition.y - this.offset.y) * this.scale) / this.preScale;this.paint(this.ctx);this.preScale = this.scale;this.curOffset.x = this.offset.x;this.curOffset.y = this.offset.y;
}paint() {this.clear();this.ctx.translate(this.offset.x, this.offset.y);this.ctx.scale(this.scale, this.scale);this.draw();
}

总结

本文从基础原理到代码实现,完整给大家讲解了 Canvas 画布绘制中经常会遇到的画布拖动和鼠标滚轮缩放功能,希望对大家有帮助,更多精彩文章欢迎大家关注我的vx公众号:前端架构师笔记。本文完整代码地址:https://github.com/astonishqft/scanvas-translate-and-scale

相关内容

热门资讯

前端-session、jwt 目录:   (1)session (2&#x...
linux入门---制作进度条 了解缓冲区 我们首先来看看下面的操作: 我们首先创建了一个文件并在这个文件里面添加了...
关于测试,我发现了哪些新大陆 关于测试 平常也只是听说过一些关于测试的术语,但并没有使用过测试工具。偶然看到编程老师...
前缀和与对数器与二分法 1. 前缀和 假设有一个数组,我们想大量频繁的去访问L到R这个区间的和,...
nodejs:本地安装nvm实... 一、背景-使用不同版本node的原因 vue3+ts、nuxt3版本,node...
JAVA集合知识整理 Java集合知识整理 HashMap相关 HashMap的底层数据结构:jdk1.8之...
无刷直流电机介绍及单片机控制实... 无刷直流电机介绍及单片机控制实例前言基本概念优势与劣势使用寿命基本结构使用单片机控制实例电子调速器&...
fwdiary(2) dp2 1.传纸条  AcWing 275. 传纸条 - AcWing 走两条路,走一条最大的...
常用的DOS命令 常用的DOS命令 DOS(Disk Operating System,磁...
<C++> 类和对象(下) 1.const成员函数将const修饰的“成员函数”称之为const成员函数,cons...