【精选】Canvas 事件处理系统 - 多图长文

Canvas 事件处理系统

是我自己的一次部门技术分享,也做了PPT和下面各种方式实现的项目代码,如果有需要的我看看再更新到哪里。

为什么我们需要事件处理系统

Canvas 是像素级的,它的绘制是直接控制像素。不管里面花了多少图形,在 DOM 里它只有一个对象 ,这在带来更好的渲染性能的同时,也带来了问题,我们对 DOM 元素的事件监听没法绑定到 Canvas 内部的元素上,只能绑定在这个大画布上,在事件发生的时候也没法去知道是发生在哪个图形上。因此为了让 Canvas 内的图形也能实现交互功能,我们要自己开发一个事件系统。

系统搭建

抽象图形

在这里插入图片描述

export class Base {
  listeners;

  constructor() {
    this.listeners = {};
  }

  draw() {
    throw new Error('Base Obj Draw');
  }

  on(eventName, listener) {
    if (this.listeners[eventName]) {
      this.listeners[eventName].push(listener);
    } else {
      this.listeners[eventName] = [listener];
    }
  }

  isPointInRegion(mouse) {
    return false;
  }
}

首先我们需要将 Canvas 里的图形抽象出来,这样在绑定事件的时候才能有的放矢。
不同的图形会有些相似的逻辑,我们先写一个基类,包括:
listeners属性 --> 记录该图形上绑定的事件监听
draw() --> 实现该图形在 Canvas 上的绘制
on() --> 绑定事件监听到该图形
isPointInRegion() --> 判断事件发生的点是不是在该图形内,返回布尔值
之后在继承基类的圆类、矩形类、线段类等类中重写 draw 和 isPointInRegion 方法就行。

画布类
export class Stage {
  #canvas;
  #ctx;
  #shapes;

  constructor(canvas) {
    const dpr = window.devicePixelRatio;
    canvas.width = parseInt(canvas.style.width) * dpr;
    canvas.height = parseInt(canvas.style.height) * dpr;

    this.#canvas = canvas;
    this.#ctx = this.#canvas.getContext('2d');
    this.#ctx.scale(dpr, dpr);

    this.#canvas.addEventListener(EventEnum.CLICK, this.handleCreator(EventEnum.CLICK));
    this.#shapes = new Set();
  }

  add(shape) {
    this.#shapes.add(shape);
    shape.draw(this.#ctx);
  }

  handleCreator = (name) => (event) => {
    this.#shapes.forEach((shape) => {
      const listeners = shape.listeners[name];
      if (listeners && shape.isPointInRegion(event)) {
        listeners.forEach((listener) => listener(event));
      }
    })
  }
}

我们除了图形类还需要一个画布类对 Canvas 这个容器进行管理。画布类属性包括 canvas 元素和记录该画布内所有绘制图形的 shapes。在构造函数里处理像素问题,给 canvas 元素绑定监听事件 handleCreator()。
add() --> 添加图形到画布上。记录到 shapes 里,并调用图形的 draw 方法绘制。
handleCreator() --> 画布的监听函数,在这个函数中会遍历画布里的所有图形的 listener,如果某个图形的 listener 中记录了相同类型事件的监听,且该图形的 isPointInRegion 返回为 true,则执行该图形记录的该类型的监听函数。

像素问题

Window 接口的 devicePixelRatio 返回当前显示设备的物理像素分辨率与 CSS 像素分辨率之比。它告诉浏览器应使用多少屏幕实际像素来绘制单个 CSS 像素。zoom in 的时候 devicePixelRatio 值会变大。
canvas 在绘制的时候,其坐标是指屏幕真实的像素点,和我们 css 定义的宽度和高度不一样。比如css定义 style=“width: 500px; height: 350px”>,输出 canvas.width, canvas.height 为 300 150,像素点只有300,150,在后面操作 canvas api 画图的时候参数值也是指的这个像素点。
所以如果我们想让 css 样式宽高和 canvas 坐标统一,并且让图像更清晰。我们先要对画布做一个初始化。

console.log('canvas.width, canvas.height', canvas.width, canvas.height); // canvas.width, canvas.height 300 150
const dpr = window.devicePixelRatio;
canvas.width = parseInt(canvas.style.width) * dpr;
canvas.height = parseInt(canvas.style.height) * dpr;
console.log('canvas.width, canvas.height', canvas.width, canvas.height); // canvas.width, canvas.height 1000 700

this.#canvas = canvas;
this.#ctx = this.#canvas.getContext('2d');
this.#ctx.scale(dpr, dpr);

scale 默认的在 canvas 中一个单位实际上就是一个像素。如果我们将 2 作为缩放因子,将会增大单位尺寸变成两个像素。形状的尺寸将会变成原来的两倍。也可以理解为坐标轴为原来的 1/2 了,这样 css 样式宽高和 canvas 坐标便统一了,并且图像清晰。

使用示例

在这里插入图片描述

判断事件发生的图形

我们过完整个流程后会发现,现在难点就在于怎么判断事件发生的点是否在某个图形上。

CanvasRenderingContext2D.isPointInPath()

isPointPath() 是 canvas 官方提供的一个解决方案,它直接调用这个 api,传入 xy 坐标,返回 true 或 false 表示传入的点是否在图形里,使用很简单。

ctx.rect(10, 10, 100, 100);
ctx.fill();
console.log('result', ctx.isPointInPath);

但是,它有巨大的缺点。它只能识别出点是不是在画的最后一个图形上,例如花了下图画了两个矩形,它的返回值只能判断点是否在第二个矩形内。
在这里插入图片描述

它还有个缺点就是:它无法识别边框,在边框很厚的时候就会出现问题。
官方的方法用不了,我们试试自己实现。

Geometry

我们是知道事件发生的鼠标坐标的,同时我们知道每个图形的绘制信息,于是我们可以想到直接用几何的知识去计算事件发生的点在不在图形内。

在这里插入图片描述

isPointInRegion(mouse) {
    const { x, y, radius, strokeWidth } = this.props;
    const pointX = mouse.clientX;
    const pointY = mouse.clientY;
    return Math.sqrt(((pointX - x) ** 2 + (pointY - y) ** 2)) <= (radius + strokeWidth /2);
}

对于圆,只需要比较点到圆心的距离和圆的半径加边框(边框的厚度是从初始边向两边同时增加,所以只需要加1/2的边框宽度)。

矩形

在这里插入图片描述

isPointInRegion(mouse) {
    const { x, y, width, height, strokeWidth } = this.props;
    const pointX = mouse.clientX;
    const pointY = mouse.clientY;
    const minX = x - strokeWidth / 2;
    const minY = y - strokeWidth / 2;
    const maxX = x + width + strokeWidth / 2;
    const maxY = y + height + strokeWidth / 2;
    return pointX >= minX && pointX <= maxX && pointY >= minY && pointY <= maxY;
}

矩形我们算出四个角的坐标,拿到 xy 坐标的最大最小值区间即可判断。

线段

在这里插入图片描述

isPointInRegion(mouse) {
    const { strokeWidth, pointOneX, pointOneY, pointTwoX, pointTwoY } = this.props;
    const pointX = mouse.clientX;
    const pointY = mouse.clientY;
    const segmentLen = Math.sqrt((pointOneX - pointTwoX) ** 2 + (pointOneY - pointTwoY) ** 2);
    const mouseToPointOne = Math.sqrt((pointOneX - pointX) ** 2 + (pointOneY - pointY) ** 2);
    const mouseToPointTwo = Math.sqrt((pointTwoX - pointX) ** 2 + (pointTwoY - pointY) ** 2);

    if (strokeWidth === 1) {
        return mouseToPointOne + mouseToPointTwo <= segmentLen + 0.01;
    } else {
        const sRec = strokeWidth * segmentLen;
        // todo
    }
}

我们知道两点间线段最短,所以比较点到线段两个端点的距离之和是不是等于两个端点的距离即可(这里因为有浮点数计算误差用了小于一个较小值来判断)。

但是如果这条线段有厚度,那就是一个斜着的矩形,我们可以想想这个应该怎么判断呢?如果是不规则的多边形又应该怎么判断呢?

多边形

常用的有两种对任意多边形的判断方法:
在这里插入图片描述

1、面积判断法:将点和多边形的顶点相连,这样可以将多边形切割成多个三角形,用海拉公式可以算出三角形的面积,比较三角形面积之和和多边形面积即可。
在这里插入图片描述

2、射线法:从点引出一条任意射线,与多边形的交点数量是奇数则点在多边形内。(不管什么方向,不考虑传过端点的情况,直线有穿入就有穿出,如果是在外部,就是"穿入穿出穿入穿出…",是偶数个,在内部,第一次是穿出,后面加上"穿入穿出穿入穿出…"是奇数个)


我们用射线法来实现,首先创建一个线段类,属性是线段的两个端点。有静态方法 getSegments(),传入一个多边形端点列表,将其依次连线生成线段列表,静态方法 lineLineIntersect(),传入两个线段,返回它们交点信息。

export class Seg2d {
  endPoints;
  
  constructor(start, end) {
    this.endPoints = [start, end];
  }

  get start() {
    return this.endPoints[0]
  }

  get end() {
    return this.endPoints[1]
  }

  static getSegments(points) {
    const list = [];
    for (let i = 1; i < points.length; i++) {
      list.push(new Seg2d(points[i - 1], points[i]));
    }
    list.push(new Seg2d(points[points.length - 1], points[0]));
    return list;
  }

  static lineLineIntersect(line1, line2) {
  }
}

在多边形的 isPointInRegion 中,因为射线法对任意射线都是成立的,为了简单我们创建水平射线(其实是创建了线段,但是第二个点的横坐标已经超过了画布,可以将其看作射线),然后调用 getSegments() 生成多边形所有的边,再逐个调用 lineLineIntersect(),判断与我们生成的射线有无交点。最后判断交点的个数即可。

  isPointInRegion(mouse) {
    const { points } = this.props;
    const allSegs = Seg2d.getSegments(points);

    // 从鼠标点击处引出的一条射线
    const start = { x: mouse.clientX, y: mouse.clientY };
    const end = { x: mouse.clientX + 3000, y: mouse.clientY }
    const anyRaySeg = new Seg2d(start, end);
    let total = 0;

    allSegs.forEach((item) => {
      const intersetSegs = Seg2d.lineLineIntersect(item, anyRaySeg);
      total += intersetSegs.length;
    })

    return total % 2 === 1;
  }

最后一个难点是如何判断线段的交点。
我们用判断一条线段的两个端点是否在另一条线段的两侧来判断两条线是否相交。
在这里插入图片描述

如图,我们做线段cd的法线,作出线段ab和线段cd在该法线上的投影点,如果线段ab的投影点在cd的投影点的两侧,则表示相交。(找不到合适的图,手绘快多了)
在这里插入图片描述

我们将原点到A点的向量与法线向量做点乘,结果为A点到法线的投影。同理求出B点C点在法线的投影,比较其值即可知道线段ab的投影点是不是在cd的投影点的两侧。
在这里插入图片描述

tips:
向量求法向量:
AB坐标: (a.x, a.y) (b.x, b.y)
那么AB向量的法线向量 (b.y - a.y, a.x - b.x)
向量的点乘叉乘:
向量的点乘,也叫向量的内积、数量积,对两个向量执行点乘运算,就是对这两个向量对应位一一相乘之后求和的操作,点乘的结果是一个标量。点乘的几何意义是可以用来表征或计算两个向量之间的夹角,以及在b向量在a向量方向上的投影,
两个向量的叉乘,又叫向量积、外积、叉积,叉乘的运算结果是一个向量而不是一个标量。并且两个向量的叉积与这两个向量组成的坐标平面垂直。在二维中,它的数值的几何意义是以两向量为邻边的平行四边形的面积,此外叉乘为0时,表示两向量共线。

Offscreen Canvas

另一种实现是巧用 Offscreen Canvas。
OffscreenCanvas 提供了一个可以脱离屏幕渲染的 Canvas 对象。它在窗口环境和 web worker 环境均有效。它常用的场景是将一些耗时的绘制先在离屏画布对象里做完,再将绘制完的图形传给屏幕上的画布。离屏画布的绘制可以在 web worker 上做,这样的好处就是不会阻塞主线程,页面依然可以交互。
我们这里是用离屏画布结合 getImageData 这个 API 去做事件点击图形的判断。
这个主要是思想是每次在画布画图形的时候,在离屏画布也画一个一模一样的,但是用唯一颜色填充满的图形。
过程有三步:首先每个绘制的图形都生成一个唯一 id,且 id 的格式就是 rgba 的数值。然后在画布画图的同时也在离屏画布画图,并将其填充 id 对应的颜色。最后在事件发生的时候,用 getImageData(x,y) 在离屏画布上取得颜色值,将其转换成 id 就知道选择的是哪个图形了。

function createOnceId() {
  return Array(3)
    .fill(0)
    .map(() => Math.ceil(Math.random() * 255))
    .concat(255)
    .join("-"); //'243-41-249-255'
}
export class Rect {
  ...

  draw(ctx, osCtx) {
    const { x, y, width, height, strokeColor, strokeWidth, fillColor } = this.props;

    ctx.save();
    ctx.beginPath();
    ctx.strokeStyle = strokeColor;
    ctx.lineWidth = strokeWidth;
    ctx.fillStyle = fillColor;
    ctx.rect(x, y, width, height);
    ctx.fill();
    ctx.stroke();
    ctx.restore();

    const [r, g, b, a] = idToRgba(this.id);

    osCtx.save();
    osCtx.beginPath();
    osCtx.strokeStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
    osCtx.fillStyle = `rgba(${r}, ${g}, ${b}, ${a})`;
    osCtx.lineWidth = strokeWidth;
    osCtx.rect(x, y, width, height);
    osCtx.fill();
    osCtx.stroke();
    osCtx.restore();
  }
}

更多事件类型

在上文中我们只对画布做了 click 事件的监听,如果需要更多事件,比如鼠标进入某图形应该做呢。

在这里插入图片描述

我们对画布监听三个事件:mousedown、mouseup和mousemove,就可以组合成更多的事件,

在这里插入图片描述

比如鼠标进出某图形,首先是需要当前画布的事件为 mousemove,并且我们的维护 lastMoveId 属性(记录每一次 mousemove 后鼠标所在的图形 id)不等于当前图形的 id,我们就可以触发当前图形的 mouseenter 事件和 lastMovedId 对应图形的 mouseleave 事件。

在这里插入图片描述

同样的逻辑可以监听更多的事件类型。

对比

离屏画布的方法简单,对比几何计算法,不需要对每种图形都开发对应的计算事件坐标是不是在其中的函数,且两种方法都可以包括对边框的判定。
但是在性能上,因为离屏画布的方法是相同图形绘制了两遍,在绘制对象多的情况下性能会低于几何计算法。
如图是在本机上的测试结果 MacBook Pro (16-inch, 2019) i7 16GB AMD 5300M

在这里插入图片描述

测试方法是每帧都重新绘制一定数量的随机位置、大小固定的矩形,横轴是绘制的图形数量,纵轴是 FPS(每秒传输帧数),蓝色的线是离屏画布,绿色的是几何计算,可以看到明显的几何计算的性能更好。但是在1000个图形以内的时候,两者的表现相近且页面动画都是很流畅的,所以个人想法在绘制图形不多的时候选择离屏画布方案是更简单且出错率小的,

参考

https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/scale
https://developer.mozilla.org/zh-CN/docs/Web/API/Window/devicePixelRatio
https://developer.mozilla.org/en-US/docs/Web/API/OffscreenCanvas#asynchronous_display_of_frames_produced_by_an_offscreencanvas
https://juejin.cn/post/6888209975965909000
https://juejin.cn/post/6996811170459942925
https://blog.logrocket.com/when-to-use-html5s-canvas-ce992b100ee8/
https://blog.csdn.net/dcrmg/article/details/52416832

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值