canvas事件模擬

一. demo預(yù)覽

image

二.前置知識(shí)

關(guān)于canvas事件模擬方式羅列

1. isPointInPath + Path2D API (存在極大的兼容性)

  • CanvasRenderingContext2D.isPointInPath()是 Canvas 2D API 用于判斷在當(dāng)前路徑中是否包含檢測(cè)點(diǎn)的方法。
  • 方法為: CanvasRenderingContext2D.isPointInPath(x, y, fillRule, path)
  • 參數(shù):
    1. x : 檢測(cè)點(diǎn)的X坐標(biāo)
    2. y : 檢測(cè)點(diǎn)的Y坐標(biāo)
    3. fillRule: 用來決定點(diǎn)在路徑內(nèi)還是在路徑外的算法。允許的值:"nonzero": 非零環(huán)繞規(guī)則 ,默認(rèn)的規(guī)則。"evenodd": 奇偶環(huán)繞原則 。
    4. path: Path2D應(yīng)用的路徑,或者當(dāng)前繪制的路徑。
  • 返回值: 一個(gè)Boolean值,當(dāng)檢測(cè)點(diǎn)包含在當(dāng)前或指定的路徑內(nèi),返回 true;否則返回 false。

2. 角度法

  • 說明:如果一個(gè)點(diǎn)在多邊形內(nèi)部,則該點(diǎn)與多邊形所有頂點(diǎn)兩兩構(gòu)成的夾角,相加應(yīng)該剛好等于360°。
  • 局限性: 圖形必須是凸多邊形,其他類型的圖形都不可以。
image

3. 射線法

  • 說明:判斷點(diǎn)與多邊形一側(cè)的交點(diǎn)個(gè)數(shù)為奇數(shù),則點(diǎn)在多邊形內(nèi)部。
  • 該方法不局限于圖形的類型,凸多邊形,凹多邊形,環(huán)形等都可以,邊界條件處理方式預(yù)覽具體情況具體分析
  • 難度:每個(gè)圖形都需要有相應(yīng)的函數(shù)判斷射線邊界
image

4.像素法

  • canvas中的圖形分別離屏繪制,通過判斷事件的位置數(shù)據(jù)(getImageData()方法獲取),是否跟事件的唯一id一致來dispatch事件

  • 當(dāng)前文章demo使用方式為像素法

5. 其他...

三. 一些特別注明

1. OffscreenCanvas
  • 構(gòu)造函數(shù)OffscreenCanvas 創(chuàng)建一個(gè)新的OffscreenCanvas對(duì)象。 提供了一個(gè)可以脫離屏幕渲染的canvas對(duì)象。它在窗口環(huán)境和web worker環(huán)境均有效。
  • 存在兼容性,并且該API之后可能廢棄,demo未作兼容處理,兼容性處理方式可以是用一個(gè)隱藏的Canvas對(duì)象代替 new OffscreenCanvas()
2. getImageData
  • CanvasRenderingContext2D.getImageData(sx, sy, sw, sh) 返回一個(gè)ImageData對(duì)象,用來描述canvas區(qū)域隱含的像素?cái)?shù)據(jù),這個(gè)區(qū)域通過矩形表示,起始點(diǎn)為(sx, sy)、寬為sw、高為sh。
  • 參數(shù):sx, sy:將要被提取的圖像數(shù)據(jù)矩形區(qū)域的左上角 x,y 坐標(biāo)。 sw, sh:將要被提取的圖像數(shù)據(jù)矩形區(qū)域的寬度, 高度
  • 注意這里getImageData().data 的取值范圍為(0,255)所以這里 rgba中 a 按照0-> 0 , 1->255的范圍。

3.正多邊形繪制方式

  • 原理是中心點(diǎn)到所有角頂點(diǎn)的集合加起來為360度

4.五角星繪制方式

  • 可以理解成內(nèi)部一個(gè)正五邊形,外部一個(gè)正五邊形,并且每個(gè)角度固定

5.心繪制方式

  • 公式: x = 16 * (sint)**3; y = 13cost - 5cons2t - 2cos3t - cos4t

6. 關(guān)于demo中取名

  • 畫的圖案小部件取名為 widget
  • 舞臺(tái)取名為 Mural
  • 隱藏canvas實(shí)例為 hideCtx

四.設(shè)計(jì)思路以及具體代碼

  • canvas事件模擬的原理是,我們知道用戶事件在哪個(gè)目標(biāo)canvas繪制的圖形之中觸發(fā), 所以我們只需要判斷在canvas 節(jié)點(diǎn)上觸發(fā)event的x,y坐標(biāo)值,所對(duì)應(yīng)的圖案是否有綁定事件,如果有那么促發(fā)該事件.
    于是 可以寫出觸發(fā)事件的偽代碼.
import { Widget, Mural } from './canvasEvent'

const Mural = new Mural(canvas對(duì)象)

const widget1 = new Widget(options)
const widget2 = new Widget(options)
const widget3 = new Widget(options)

widget1.on('事件名1', callback1)
widget2.on('事件名2', callback2)
widget3.on('事件名3', callback3)
    
Mural.add(widget1) // 如果在widget1上促發(fā)事件1 調(diào)用callback1
Mural.add(widget2) // 如果在widget2上促發(fā)事件2 調(diào)用callback2
Mural.add(widget3) // 如果在widget3上促發(fā)事件3 調(diào)用callback3

這里的widget是很多各種類型所要監(jiān)聽圖案實(shí)例的總稱,所以這里可以設(shè)計(jì)一個(gè)base類,抽離公共方法, 子類繼承父類的方法,并且自定義方法形成多種形態(tài). 貼出wiget Base類的代碼.

export class Base {

  constructor(props){
    this.id = createId()
    this.listeners = {}
    this.isAnimation = props.isAnimation || false // 這個(gè)元素是否需要移動(dòng)位置,以及是否需要重疊
  }

  draw (){
    throw new Error('this widget not have draw methods')
  }

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

  getListeners() {
    return this.listeners
  }

  getId(){
    return this.id
  }

  getIsAnimation(){
    return this.isAnimation
  }
}
  • 在base類的基礎(chǔ)上,我們可以定義各種形態(tài)的widget,列如最簡(jiǎn)單的rect.
import { Base } from './Base';
export class Rect extends Base {
  constructor(props) {
    super(props);
    this.options = {
      x: props.x,
      y: props.y,
      width: props.width,
      height: props.height,
      fillColor: props.fillColor || '#fff',
      strokeColor: props.strokeColr || '#000',
      strokeWidth: props.strokeWidth || 1
    };
  }

  draw(ctx, hideCtx) {
    const { x, y, width, height, fillColor, strokeColor, strokeWidth } = this.options;
    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();
    ....
  }
}

寫出Mural代碼的架構(gòu).
export class Mural {
  constructor(canvas) {
    // canvas 在不同dpr屏幕上的模糊問題
    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); // 根據(jù)dpr 縮放畫布

    this.canvas.addEventListener('mousedown', callback);
    this.canvas.addEventListener('mouseup', callback);
    this.canvas.addEventListener('mousemove', callback);
  }

  add(widget) {
    widget.draw(this.ctx);
  }
}

那么怎么通過 this.canvas.addEventListener('事件名', callback); 促發(fā)widget.on中的回調(diào)函數(shù)呢? 于是有下一步代碼.

Mural


export class Mural {
  constructor(canvas) {
    // canvas 在不同dpr屏幕上的模糊問題
    const dpr = window.devicePixelRatio;
    canvas.width = parseInt(canvas.style.width) * dpr;
    canvas.height = parseInt(canvas.style.height) * dpr;

    // 如果無法使用這個(gè)API可以畫在一個(gè)隱藏的canvas上
    this.hidecanvas = new OffscreenCanvas(canvas.width, canvas.height);

    this.canvas = canvas;
    this.ctx = this.canvas.getContext('2d');
    this.hideCtx = this.hidecanvas.getContext('2d');
    this.ctx.scale(dpr, dpr); // 根據(jù)dpr 縮放畫布
    this.hideCtx.scale(dpr, dpr); // 根據(jù)dpr 縮放畫布
    this.dpr = dpr;

    this.canvas.addEventListener('mousedown', this.handleCreator(ActionTypes.down));
    this.canvas.addEventListener('mouseup', this.handleCreator(ActionTypes.up));
    this.canvas.addEventListener('mousemove', this.handleCreator(ActionTypes.move));

    this.widgets = new Set(); // 將所有的部件放入Set容器中

    this.eventAnglogies = new EventAnglogies();
  }

  add(widget) {
    const id = widget.getId();
    this.eventAnglogies.addListeners(id, widget.getListeners());
    this.widgets.add(id);
    widget.draw(this.ctx, this.hideCtx);
  }

  handleCreator = (type) => (ev) => {
    const x = ev.offsetX;
    const y = ev.offsetY;
    const id = this.getHideId(x, y);
    this.eventAnglogies.addAction({ type, id }, ev);
  };

  getHideId(x, y) {
    const rgba = [ ...this.hideCtx.getImageData(x * this.dpr, y * this.dpr, 1, 1).data ];

    const id = rgbaToId(rgba);

    return this.widgets.has(id) ? id : undefined;
  }
}

Rect

import { idToRgba } from '../lib/helper';
import { Base } from './Base';

export class Rect extends Base {
  constructor(props) {
    super(props);
    this.options = {
      x: props.x,
      y: props.y,
      width: props.width,
      height: props.height,
      fillColor: props.fillColor || '#fff',
      strokeColor: props.strokeColr || '#000',
      strokeWidth: props.strokeWidth || 1
    };
  }

  draw(ctx, hideCtx) {
    const { x, y, width, height, fillColor, strokeColor, strokeWidth } = this.options;
    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.getId());

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

helper.js

export const rgbaToId = (rgba) => rgba.join('-');

// 這里最多可以繪制圖形 256*256*256個(gè)  16,777,216 約1600萬個(gè)
const idPool = {};

export const createId = () => {
  let id = createOnceId();

  while (idPool[id]) {
    id = createOnceId();
  }
  // console.log(id)
  return id;
};

export const createOnceId = () => Array(3).fill(0).map(() => Math.ceil(Math.random() * 255)).concat(255).join('-');

// 判斷兩個(gè)set容器相等,注意這里只判斷字符串類型的set容器
export const equalSet = (a, b)=> [...a].join('') === [...b].join('')

// set容器的差值
export const diffSet = (a, b) => new Set([...a].filter(x => !b.has(x)));
  • 不難發(fā)現(xiàn)有一個(gè)核心的關(guān)鍵點(diǎn),通過在隱藏畫布上畫純色的rgba值,然后通過事件得到x,y坐標(biāo),在隱藏的畫布上獲取x,y坐標(biāo)的rgba值,這里的rgba值就是對(duì)應(yīng)的id值,就可以通過該id值,和事件綁定比較,從而觸發(fā)函數(shù)。

  • 那么如何解決多個(gè)圖案重疊的問題,以及當(dāng)圖案需要變化的問題?這里采用了繪制多個(gè)離屏canvas方案,在多個(gè)離屏canvas畫布中畫固定rgba值,通過比較促發(fā)的idSet容器,得到所要促發(fā)的事件。

進(jìn)一步Mural代碼

import { EventAnglogies, ActionTypes } from './EventAnglogies';
import { rgbaToId } from './lib/helper';

export class Mural {
  constructor(canvas) {
    // canvas 在不同dpr屏幕上的模糊問題
    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); // 根據(jù)dpr 縮放畫布

    // 創(chuàng)建一個(gè)隱藏的ctx 如果無法使用這個(gè)API可以畫在一個(gè)隱藏的canvas上
    this.hideCtx = this.createHideCtx(canvas.width, canvas.height, dpr)


    this.dpr = dpr;
    // 需要即時(shí)移動(dòng)的canvas隱藏畫布
    this.moveHideCtxMap = new Map()

    this.canvas.addEventListener('mousedown', this.handleCreator(ActionTypes.down));
    this.canvas.addEventListener('mouseup', this.handleCreator(ActionTypes.up));
    this.canvas.addEventListener('mousemove', this.handleCreator(ActionTypes.move));

    this.widgets = new Set(); // 將所有靜態(tài)部件放入Set容器中
    this.widgetsMap = new Map()

    this.eventAnglogies = new EventAnglogies();
  }

  createHideCtx(width, height, dpr) {
    const hidecanvas = new OffscreenCanvas(width, height);
    const hideCtx = hidecanvas.getContext('2d');
    hideCtx.scale(dpr, dpr);
    return hideCtx
  }


  add(widget, isOld = false) {
    // 這里代表了動(dòng)畫,或者其他,就是事件已經(jīng)綁定好了,只是一些位置發(fā)生改變
    if(isOld){
      this.drawAll(widget)
      return
    }
    const id = widget.getId();
    const isAnimation = widget.getIsAnimation()
    this.eventAnglogies.addListeners(id, widget.getListeners());
    this.widgets.add(id);
    this.widgetsMap.set(id, widget)
    let hideCtx = this.hideCtx

    // 如果該widget需要移動(dòng)的話或者覆蓋, 存在的話加上,不存在的話new, 防止用戶多次add
    if (isAnimation) {
      if (this.moveHideCtxMap.get(id)) hideCtx = this.moveHideCtxMap.get(id)
      else {
        hideCtx = this.createHideCtx(this.canvas.width, this.canvas.height, this.dpr)
        this.moveHideCtxMap.set(id, hideCtx)
      }
    }

    widget.draw(this.ctx, hideCtx);
  }

  handleCreator = (type) => (ev) => {
    const x = ev.offsetX;
    const y = ev.offsetY;
    const idSet = this.getHideIdSet(x, y);
    // 不能在這里遍歷idSet
    this.eventAnglogies.dispatchAction({ type, idSet }, ev)
  };

  getHideIdSet(x, y) {
    const rgba = [...this.hideCtx.getImageData(x * this.dpr, y * this.dpr, 1, 1).data];
    const staticRgbaToId = rgbaToId(rgba);

    const staticId = this.widgets.has(staticRgbaToId) ? staticRgbaToId :[]

    let animationId = []
    
    this.moveHideCtxMap.forEach((hCtx, id)=>{
      if(rgbaToId([...hCtx.getImageData(x * this.dpr, y * this.dpr, 1, 1).data]) === id){
        animationId.push(id)
      }
    })
    // 獲取到所有當(dāng)前位置的關(guān)于動(dòng)靜態(tài)id的組合
    return new Set(animationId.concat(staticId))
  }

  // 產(chǎn)生動(dòng)畫重繪所有的圖案
  drawAll(moveWidget){
    // 清空視口畫布
    this.ctx.clearRect(0, 0 , this.canvas.height, this.canvas.width)
    this.widgetsMap.forEach((widget, id)=>{
      const hideCtx = this.moveHideCtxMap.get(id) || this.hideCtx
      // 如果不是當(dāng)前widget 直接畫,如果是當(dāng)前widget 清空隱藏的Rect
      // 因?yàn)橹匦耫raw之后又會(huì)有一次hideCtx記錄
      if(moveWidget !== widget) widget.draw(this.ctx, hideCtx);
      else hideCtx.clearRect(0, 0 , this.canvas.height, this.canvas.width)
    })
    const moveId = moveWidget.getId();
    const moveCtx = this.moveHideCtxMap.get(moveId)
    moveWidget.draw(this.ctx, moveCtx)
  }
}

EventAnglogies.js

import { equalSet, diffSet } from './lib/helper'

export const ActionTypes = {
  down: 'down',
  up: 'up',
  move: 'move'
};

export const EventNames = {
  click: 'click',
  mousedown: 'mousedown',
  mousemove: 'mousemove',
  mouseup: 'mouseup',
  mouseenter: 'mouseenter',
  mouseleave: 'mouseleave'
};

export class EventAnglogies {
  listenersMap = {};
  lastDownIdSet = new Set(); // 最后一個(gè)按下的一堆idSet
  lastMoveIdSet = new Set(); // move的idSet

  dispatchAction(action, ev) {

    const { type, idSet } = action;
    
    if (type === ActionTypes.move) {
      // mousemove
      this.fire(idSet, EventNames.mousemove, ev);

      // mouseenter
      const enterSet = diffSet(idSet, this.lastMoveIdSet)
      enterSet.size && this.fire(enterSet, EventNames.mouseenter, ev)

      // mouseleave
      const leaveSet = diffSet(this.lastMoveIdSet, idSet)
      leaveSet && this.fire(leaveSet, EventNames.mouseleave, ev)
    }

    // mousedown
    if (type === ActionTypes.down) {
      this.fire(idSet, EventNames.mousedown, ev);
    }

    // mouseup
    if (type === ActionTypes.up) {
      this.fire(idSet, EventNames.mouseup, ev);
    }

    // click
    if (type === ActionTypes.up && equalSet(this.lastDownIdSet, idSet)) {
      this.fire(idSet, EventNames.click, ev);
    }

    if (type === ActionTypes.move) this.lastMoveIdSet = action.idSet;
    else if (type === ActionTypes.down) this.lastDownIdSet = action.idSet;
  }

  addListeners(id, listeners) {
    this.listenersMap[id] = listeners;
  }

  fire(idSet, eventName, ev) {
    idSet.forEach(id => {
      if (this.listenersMap[id] && this.listenersMap[id][eventName]) {
        this.listenersMap[id][eventName].forEach((listener) => listener(ev));
      }
    })
  }
}

到此為止步,再回頭看看預(yù)覽的demo 圖示

image

這里再貼出入口文件的代碼,就一目了然了。

import { Circle, Mural, Rect, Heart, FivePointedStar, Polygon } from './canvasEvent';
import { EventNames } from './canvasEvent/EventAnglogies';

const canvas = document.querySelector('#canvas');

const mural = new Mural(canvas);

const circle = new Circle({
  x: 350,
  y: 50,
  radius: 50,
  fillColor: 'pink'
});

const rect = new Rect({
  x: 10,
  y: 10,
  width: 120,
  height: 60,
  fillColor: 'yellow'
});

const heart = new Heart({
  x: 200,
  y: 50,
  heartA: 3,
  fillColor: 'red'
});

const polygon = new Polygon({
  x:500,
  y: 50,
  n: 8,
  size: 50,
  fillColor: 'blue',
  isAnimation: true
})

const fivePoint = new FivePointedStar({
  x: 50,
  y: 200,
  minSize: 25,
  maxSize: 50,
  fillColor: 'red',
  isAnimation: true
});

rect.on(EventNames.click, () => {
  alert('點(diǎn)擊了矩形');
});

heart.on(EventNames.mouseenter, () => {
  console.log('進(jìn)入心');
});
heart.on(EventNames.mouseleave, () => {
  console.log('離開心');
});

circle.on(EventNames.click, () => {
  alert('點(diǎn)擊了圓');
});

circle.on(EventNames.mouseleave, () => {
  console.log('離開了圓形');
});


polygon.on(EventNames.mousedown, (e) => {
  console.log(polygon)
  let baseX = e.pageX
  let baseY = e.pageY
  document.onmousemove = (event) =>{
    const moveX = event.pageX - baseX
    const moveY = event.pageY - baseY
    baseX = event.pageX
    baseY = event.pageY
    polygon.options.x = polygon.options.x + moveX
    polygon.options.y = polygon.options.y + moveY
    mural.add(polygon);
  }
})

fivePoint.on(EventNames.mouseenter, () => {
  console.log('進(jìn)入了五角星');
});

fivePoint.on(EventNames.mouseleave, () => {
  console.log('離開了五角星');
});

fivePoint.on(EventNames.mousedown, (e) => {
  let baseX = e.pageX
  let baseY = e.pageY
  document.onmousemove = (event) =>{
    const moveX = event.pageX - baseX
    const moveY = event.pageY - baseY
    baseX = event.pageX
    baseY = event.pageY
    fivePoint.options.x = fivePoint.options.x + moveX
    fivePoint.options.y = fivePoint.options.y + moveY
    mural.add(fivePoint, true);
  }
})


document.addEventListener('mouseup', function() {
  document.onmousemove = null
}, false)

mural.add(circle);
mural.add(rect);
mural.add(heart);
mural.add(polygon);
mural.add(fivePoint);

以上總共解決了canvas事件模擬:

  1. mousedown事件
  2. mouseup事件
  3. mouseenter事件
  4. mousemove事件
  5. click事件
  6. 多個(gè)圖案重疊事件監(jiān)聽
  7. 圖片變動(dòng)后的事件監(jiān)聽
到這里,canvas事件模擬像素點(diǎn)法就介紹到這,具體業(yè)務(wù),要根據(jù)實(shí)際業(yè)務(wù)中方案選擇。

五.未做的一些兼容性處理

  • e = e || window.event
  • OffscreenCanvas
  • ...

若有不明之處, github地址, 可運(yùn)行demo調(diào)試。

參考文檔

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容