Canvas鼠标滚轮缩放以及画布拖动(图文并茂版)

news2024/12/26 11:59:26

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

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

Canvas的坐标变换

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

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.xevent.y,另一个是 event.offsetXevent.offsetY,其中,event.xevent.y 获取的是鼠标点击时相对于屏幕的坐标,而 event.offsetXevent.offsetY 是相对于 Canvas 容器的坐标。

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

事件坐标系.png

在构造函数中添加对 Canvasmousedown 事件监听,记录点击鼠标时相对屏幕的位置 xy

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;
    }
  }
}

画布拖动的整体思路就是利用前面介绍的 Canvastranslate 方法。画布的整体偏移量记录在 offset.xoffset.y,鼠标触发 mousedown 事件时,记录当前鼠标点击的位置相对于屏幕的坐标 x, 和 y,并且开始监听鼠标的 mousemovemouseup 事件。鼠标触发 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.xcurOffset.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.y
      window.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. 画布的清空问题

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

实现鼠标滚轮缩放

效果

实现原理

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

计算放大系数

监听鼠标滚轮的 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 * ny1 = y1 * n

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

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

缩放原理图.png

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

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

x1 = x * n;
y1 = y * n

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

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/338125.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

C++设计模式(13)——装饰模式

亦称&#xff1a; 装饰者模式、装饰器模式、Wrapper、Decorator 意图 装饰模式是一种结构型设计模式&#xff0c; 允许你通过将对象放入包含行为的特殊封装对象中来为原对象绑定新的行为。 问题 假设你正在开发一个提供通知功能的库&#xff0c; 其他程序可使用它向用户发…

注册ChatGPT的辛酸血泪史,不能算教程的教程

注册ChatGPT的血泪史 2月份了&#xff0c;改论文降重了&#xff0c;所以想搞个ChatGPT玩玩&#xff0c;本以为有渠道能顺序上车&#xff0c;但是看了很多教程&#xff0c;也进了很多交流群&#xff0c;都喵的(要不是卖号&#xff0c;租体验&#xff0c;liar)&#xff0c;所以自…

java ssm高校教材管理平台 idea maven

设计并且实现一个基于JSP技术的高校教材管理平台的设计与实现。采用MYSQL为数据库开发平台&#xff0c;SSM框架&#xff0c;Tomcat网络信息服务作为应用服务器。高校教材管理平台的设计与实现的功能已基本实现&#xff0c;主要学生、教材管理、学习教材、教材入库、教材领取、缴…

C语言 大数加法 大数乘法

最近刷题&#xff0c;总遇到大数加法&#xff08;浮点数&#xff09;和乘法问题(阶乘)&#xff0c;总结一下思路。 大数乘法主要思想&#xff1a;编程实现竖式乘法&#xff08;小学时候学的列竖式计算乘法&#xff09;创建一个很大的数组&#xff0c;用于存储大数的每一位。&am…

Android Q WiFi 代码框架

同学,别退出呀,我可是全网最牛逼的 WIFI/BT/GPS/NFC分析博主,我写了上百篇文章,请点击下面了解本专栏,进入本博主主页看看再走呗,一定不会让你后悔的,记得一定要去看主页置顶文章哦。 1 wifi 架构图 备注:在Android 9.0中,WiFi的状态处理在WifiStateMachine中进行,到…

c语言数据结构-图的遍历

(创作不易&#xff0c;感谢有你&#xff0c;你的支持&#xff0c;就是我前行的最大动力&#xff0c;如果看完对你有帮助&#xff0c;请留下您的足迹&#xff09; 目录 定义&#xff1a; 两种遍历方法&#xff1a; 深度优先搜索&#xff08;DFS&#xff09;&#xff1a; …

ElasticJob-Lite架构篇 - 认知分布式任务调度ElasticJob-Lite

前言 本文基于 ElasticJob-Lite 3.x 版本展开分析。 如果 Quartz 集群中有多个服务端节点&#xff0c;任务决定在哪个服务端节点上执行的呢&#xff1f; Quartz 采用随机负载&#xff0c;通过 DB 抢占下一个即将触发的 Trigger 绑定的任务的执行权限。 在 Quartz 的基础上&…

从0到1一步一步玩转openEuler--10 openEuler基础配置-设置kdump

10 openEuler基础配置-设置kdump 文章目录10 openEuler基础配置-设置kdump10.1 设置kdump10.1.1 设置kdump预留内存10.1.1.1 预留内存参数格式10.1.2 预留内存推荐值10.1.3 禁用网络相关驱动10.1 设置kdump 本节介绍如何设置kdump预留内存及修改kdump配置文件参数。 10.1.1 设…

写python爬虫,你永远绕不过去代理问题

如果你想要从事 Python 爬虫相关岗位&#xff0c;那你一定会接触到代理问题&#xff0c;随之而来的就是下面 5 大代理知识点。 什么是代理&#xff1a;代理是网络中间人&#xff08;中间商赚插件&#xff09;&#xff0c;它代表用户发送网络请求&#xff0c;隐藏用户的真实身份…

JDY-31蓝牙模块使用指南

前言 本来是想买个hc-05&#xff0c;这种非常常用的模块&#xff0c;但是在优信电子买的时候&#xff0c;说有个可以替代的&#xff0c;没注意看&#xff0c;买回来折腾半天。 这个模块是从机模块&#xff0c;蓝牙模块分为主机从机和主从一体的&#xff0c;主机与从机的区别就…

【安全】nginx反向代理+负载均衡上传webshel

Nginx负载均衡下上传webshell 什么是反向代理&#xff1f; 正向代理就是代替客户端进行各种服务的访问以及获取&#xff1b;那么反向代理自然就是代替服务器进行事务处理&#xff0c;就是此时的代理服务器负责将用户的各项请求做一个汇总、分类&#xff0c;将其分发到不同的服务…

网络抓包方式复现Tomcat- AJP协议文件读取/命令执行漏洞(CVE-2020-1938 / CNVD-2020-10487)

目录 测试是否安装成功​编辑 基础简介 Tomcat Connector(连接器) ​编辑Servlet(服务程序) Tomcat内部处理请求流程 Tomcat加载和处理jsp的流程图 抓包复现 需要将下图中抓取到的数据包修改一下 替换成二进制数据的形式&#xff1a; python版替换代码&#xff1a; 运…

WordPress网站日主题Ri主题RiProV2主题开启了验证码登录但是验证码配置不对结果退出登录后进不去管理端了

背景 WordPress网站日主题Ri主题RiProV2主题开启了验证码登录但是验证码配置不对结果退出登录后进不去管理端了;开启了腾讯云验证码防火墙但APPID,APPSecret没配置,结果在退出登录后,由于验证码验证失败管理端进不去了 提示如下:

自定义软件帮助文档(qt assistant实现)

网上搜了一下&#xff0c;软件的帮助文档&#xff0c;三个都可以&#xff1a;https://github.com/zealdocs/zeal&#xff0c;https://zealdocs.org/&#xff0c;看看这个博客说的 https://blog.csdn.net/libaineu2004/article/details/125028913&#xff0c;这个也是开源的&…

神经网络实战--使用迁移学习完成猫狗分类

前言&#xff1a; Hello大家好&#xff0c;我是Dream。 今天来学习一下如何使用基于tensorflow和keras的迁移学习完成猫狗分类&#xff0c;欢迎大家一起前来探讨学习~ 本文目录&#xff1a;一、加载数据集1.调用库函数2.加载数据集3.数据集管理二、猫狗数据集介绍1.猫狗数据集介…

【Spring(十一)】万字带你深入学习面向切面编程AOP

文章目录前言AOP简介AOP入门案例AOP工作流程AOP切入点表达式AOP通知类型AOP通知获取数据总结前言 今天我们来学习AOP,在最初我们学习Spring时说过Spring的两大特征&#xff0c;一个是IOC,一个是AOP,我们现在要学习的就是这个AOP。 AOP简介 AOP:面向切面编程,一种编程范式&#…

计算机网络自顶向下 -- 流水线,滑动窗口协议

流水线协议 Rdt3.0在停等操作的过程中浪费了大量的时间&#xff1a; 从而在Rdt 3.0上引入了流水线机制&#xff1a;为了提高资源利用率 流水线协议&#xff1a; 允许发送方在收到ACK之前连续发送多个分组&#xff0c;更大的序列号范围&#xff0c;同时发送方和/或接收方需要更…

关于自动驾驶高精定位的几大问题

交流群 | 进“传感器群/滑板底盘群”请加微信号&#xff1a;xsh041388交流群 | 进“汽车基础软件群”请加微信号&#xff1a;Faye_chloe备注信息&#xff1a;群名称 真实姓名、公司、岗位作者 | 许良定位是高等级自动驾驶的基础&#xff0c;但在高速NOA和城区NOA等场景中&…

Linux账号与用户组

目录 用户标识符&#xff1a;UID与GID 用户账号 /etc/passwd文件结构 1、账号名称 2、密码 3、UID 4、GID 5、用户信息说明栏 6、家目录 7、shell /etc/shadow文件结构 1、账号名称 2、密码 3、最近修改密码的日期 4、密码不可被修改的天数&#xff08;与第三字…

Git | 在IDEA中使用Git

目录 一、在IDEA中配置Git 1.1 配置Git 1.2 获取Git仓库 1.3 将本地项目推送到远程仓库 1.4 .gitignore文件的作用 二、本地仓库操作 2.1 将文件加入暂存区 2.2 将暂存区的文件提交到版本库 2.3 查看日志 三、远程仓库操作 3.1 查看和添加远程仓库 3.2 推送至远程仓…