canvas也能實現事件系統????

FE_Fly發表於2021-08-18

前言

大家好! 我是熱愛圖形的fly, 之前在群裡和粉絲討論canvas 如何事件系統, 然後呢? 我自己其實也對這個比較感興趣, 我看過很多canvas 實現的專案, 比如canvas 實現思維導圖 xmind , canvas 實現一個繪圖工具。 然後呢無論是哪一個,其實背後都是在canavs 背後實現了一套事件系統,可惜這些原始碼都不開源。所以本著學習的激情, 我參考了一些文章實現一個簡單事件系統。本篇文章你可以學到下面?這些內容

  1. 我是怎麼基於canvas去構建基礎框架
  2. 幾何演算法—— 判斷點是不是任意多邊形內部
  3. 如何進行事件分發阻止事件冒泡

本篇文章我全是乾貨。歡迎點贊、關注、收藏。

基礎框架的搭建

圖形類

第一步我要做的事就是進行概念抽象,大家去想一下,canvas本質是一層畫布,然後畫布上很多圖形,有長方形、圓形、以及任意閉合的多邊形. 從物件導向的角度考慮的話, 我們可以封裝一個基類 —— shape 每個圖形是不是都在canvas 去顯示,所以都應該有一個

draw 方法, 還有一個方法就是判斷滑鼠的點 是不是在當前圖形的內部,這個我我們後面在討論吧。 然後每個圖形有自己的特有的屬性,結合canvas 的api 去設定。

export class Circle extends Shape {
  constructor(props) {
    super()
    this.props = props
  }

  draw(ctx) {
  }

  // 判斷滑鼠的點是否在圖形內部
  isPointInClosedRegion(mouse) {
  }
}

export class Rect extends Shape {
  constructor(props) {
    super()
    this.props = props
  }
  draw(ctx) {
  }

  // 判斷滑鼠的點是否在圖形內部
  isPointInClosedRegion(mouse) {
  }
}

上面兩個圖形看結構都是一樣的,不一樣的draw方法, 我給你1分鐘時間思考?下,canvas 是如何畫矩形和畫圓的。 其實 就是兩個api一個 arc一個rect 然後 你傳入對應的引數就好了。這裡沒什麼, 不知道的同學可以去MDN去看下, 我已經講了很多篇了。我就直接給出程式碼:

const { center, radius, fillColor = 'black' } = this.props
const { x, y } = center
ctx.save()
ctx.beginPath()
ctx.fillStyle = fillColor
ctx.arc(x, y, radius, 0, Math.PI * 2)
ctx.fill()
ctx.closePath()
ctx.restore()

這是圓的, save 和 restore 的方法 妙處 就是 比如我給圓設定紅色 ,如果我再去畫矩形, 矩形也會變成紅色, 這樣就不可控了,圓的話就是 圓心 加 半徑,加填充顏色。

看完圓的我們在看下矩形的。

const { leftTop, width, height, fillColor = 'black' } = this.props
const { x, y } = leftTop
ctx.save()
ctx.beginPath()
ctx.fillStyle = fillColor
ctx.fillRect(x, y, width, height)
ctx.closePath()
ctx.restore()

矩形的屬性 一個左上角的點一個長度,一個寬度。ok ,到這裡圖形基本搭建完成,下面開始搭建畫布類

畫布類

畫布類的目前做的事情非常簡單哈,初始化一些屬性。首先他有個add() 方法,去往畫布增加各個圖形。增加的圖形,每一個圖形內部都去實現了draw 方法。這樣實現了往canvas 加圖形的操作哈。直接看程式碼:

// 新建一個畫布類
export class Canvas {
  constructor() {
    this.canvas = document.getElementById('canvas')
    this.ctx = this.canvas.getContext('2d')
    this.allShapes = []
  }

  add(shape) {
    shape.draw(this.ctx)
    this.allShapes.push(shape)
  }
}

是不是很簡單,我們寫一些程式碼測試下:

const canvas = new Canvas()
const circle = new Circle({
  center: new Point2d(50, 50),
  radius: 50,
  fillColor: 'green',
})
const rect = new Rect({
  leftTop: new Point2d(50, 50),
  width: 100,
  height: 100,
  fillColor: 'black',
})
// 新增
canvas.add(circle)
canvas.add(rect)

這樣寫程式碼是不是感覺十分的舒服, 很清除, 可讀性非常的高哇

畫布建立

OK,看來我們寫的程式碼是沒有問題的,下面寫一個稍微複雜的圖形,任意點組成的閉合polygon

polygon類

同樣是也是有draw 和 isPointInClosedRegion 這個兩個方法, 畫圖的這個方法呢, 屬性就是一堆2d點, 第一個點是移動畫筆?, 其餘的點呼叫canvas lineTo的方法。 然後 閉合區域就好了 。

export class Polygon extends Shape {
  constructor(props) {
    super()
    this.props = props
  }
  draw(ctx) {
    const { points, fillColor = 'black' } = this.props
    ctx.save()
    ctx.beginPath()
    ctx.fillStyle = fillColor
    points.forEach((point, index) => {
      const { x, y } = point
      if (index === 0) {
        ctx.moveTo(x, y)
      } else {
        ctx.lineTo(x, y)
      }
    })
    ctx.fill()
    ctx.closePath()
    ctx.restore()
  }

  getDispersed() {
    return this.props.points
  }

  isPointInClosedRegion(event) {
  }
}

測試的話,我是隨機在畫布取了5個點, 我用了我之前寫的Point2d類, 上有個random方法, 傳入canvas 的長度和寬度。不清楚的同學看看我之前寫 canvas實現點的移動, 那裡 我有詳細介紹過。測試程式碼如下:

const points = []
for (let i = 0; i < 5; i++) {
  points.push(Point2d.random(800, 600))
}
const shape = new Polygon({
  points,
  fillColor: 'orange',
})
// 新增到畫布中
canvas.add(shape)

我們看下結果:

三個圖形

基類shape

寫到這裡就有人問到, 這個三個類 都繼承 基類 shape, shape 有什麼通用的能力呢? 這裡開始到我們本文的主題了, 就是每個圖形的是不是有監聽事件, 事件有很多種型別。每個型別下肯定有一大堆的監聽函式, OK ,首先這是大家通用的能力, 或者是大家都需要的額東西, 我們就把放在基類中就好了, 那麼我們用什麼資料結構去儲存呢—— 這種key Value 一看就是用Map, 行吧我們看下程式碼吧:

// 圖形的基類
export class Shape {
  constructor() {
    this.listenerMap = new Map()
  }
  on(eventName, listener) {
    if (this.listenerMap.has(eventName)) {
      this.listenerMap.get(eventName).push(listener)
    } else {
      this.listenerMap.set(eventName, [listener])
    }
  }
}

On 這個方法哈, 第一個引數是事件名字, 第二個引數就是listener了, OK到目前為止, 每個圖形對應的事件,都有了listener。

事件分發

這個小節,就是將所有canvas 繫結的事件,傳遞到每個圖形上去。第一步哈,我們首先為canvas 繫結監聽函式。

小Tips: 為canvas 增加鍵盤事件的時候,需要給canvas 增加一個屬性 tabinex = 0 , 不然 繫結無效。

this.canvas.addEventListener(move, this.handleEvent(move))this.canvas.addEventListener(click, this.handleEvent(click))

Move 和click 是我定義個兩個常量哈:

export const move = 'mousemove'export const click = 'mousedown'

handleEvent 這個方法 用到了函數語言程式設計, 將事件名字 和邏輯 進行解耦哇。

handleEvent = (name) => (event) => {    this.allShapes.forEach((shape) => {      // 獲取當前事件的所有監聽者      const listerns = shape.listenerMap.get(name)      if ( listerns ) {        listerns.forEach((listener) => listener(event))      }    })  }

這樣其實就實現了事件的分發,我們來測試下:

circle.on(click, (event) => {  //event.isStopBubble = true  console.log(event, 'circle')})rect.on(click, (event) => {  console.log(event, 'rect')})

事件系統點選

不知道大家有沒有發現問題, 雖然我們實現了事件分發,但是存在一個問題,我在畫布上任意一點選, 都會觸發,可能其實我點選的根本不在我畫的圖形內部。所以我們進行事件分發的時候,還要判斷下滑鼠的點 是不是在閉合的區域內部。所以說呢,每一個shape 內部都要去實現 isPointInClosedRegion 這個方法。

圓的實現

判斷一個點是不是在於圓內,這個其實很簡單,主要去比較 滑鼠的點 和圓心的距離 與 半徑做比較,然後就可以判斷了哈, 這個沒什麼。直接上程式碼:

const { center, radius } = this.propsreturn mouse.point.distance(center) <= radius * radius

矩形的實現

判斷一個點是不是在矩形內, 這裡其實有個包圍盒的概念,但是矩形 本來就是方方正正的,所以第一部根據, 左上角的點,算出矩形的minX, minY, maxX,maxY 然後 去拿滑鼠的點去比較就好了。 這裡我給大家畫個圖:

矩形的包圍盒

看到這張圖應該不用說什麼了, 直接上程式碼:

  // 判斷滑鼠的點是否在圖形內部  isPointInClosedRegion(mouse) {    const { x, y } = mouse.point    const { leftTop, width, height } = this.props    const { x: minX, y: minY } = leftTop    const maxX = minX + width    const maxY = minY + height    if (x >= minX && x <= maxX && y >= minY && y <= maxY) {      return true    }    return false  }

點在任意多邊形內(演算法)

簡單的圖形我們可以通過一個數學關係去比較,但是複雜的多邊形呢, 多邊形分為 凹多邊形凸多邊形。那我們該怎麼去解決呢?社群有下面幾種方法:

  1. 引射線法:從目標點出發引一條射線,看這條射線和多邊形所有邊的交點數目。如果有奇數個交點,則說明在內部,如果有偶數個交點,則說明在外部。
  2. 面積和判別法:判斷目標點與多邊形的每條邊組成的三角形面積和是否等於該多邊形,相等則在多邊形內部。

具體做法:將測試點的Y座標與多邊形的每一個點進行比較,會得到一個測試點所在的行與多邊形邊的交點的列表。在下圖的這個例子中有8條邊與測試點所在的行相交,而有6條邊沒有相交。如果測試點的兩邊點的個數都是奇數個則該測試點在多邊形內,否則在多邊形外。在這個例子中測試點的左邊有5個交點,右邊有三個交點,它們都是奇數,所以點在多邊形內。

example

這裡有人會問為什麼奇數是在內部, 偶數是在外部呢?

我以最簡單的例子,帶你去解釋為什麼?這時候又到了, 畫圖時刻:

我們先從內部選一個點,然後向任意方向發出一條射線。 你會發現一個問題,我們射線第一次與直線相交 叫做 穿入, 後面再相交 叫做穿出, 你會發現內部的最後永遠是穿入,沒有穿出, 但是外部的點, 永遠穿入的同時, 然後穿出。最後永遠是穿出

內部的點

外部的點

演算法實現

這裡涉及到一個主要的演算法就是 線段 和線段求焦點。我們新建一個Seg2d的類 線段肯定是有兩個端點:

export class Seg2d {  constructor(start, end) {    this.endPoints = [start, end]    this._asVector = undefined  }  get start() {    return this.endPoints[0]  }  get end() {    return this.endPoints[1]  }  reverse() {    return new Seg2d(this.end.clone(), this.start.clone())  }  clone() {    return new Seg2d(this.start.clone(), this.end.clone())  }  get asVector() {    return (      this._asVector ||      (this._asVector = new Point2d(        this.endPoints[1].x - this.endPoints[0].x,        this.endPoints[1].y - this.endPoints[0].y      ))    )  }}

這都是基本操作沒什麼好講的, 主要在類上 實現了 兩個靜態方法

  1. 多個點轉成線段
  2. 線段和線段相交

我先來講第一個,因為我們我們傳給任意多邊形的就是 點的集合, 所以,我們得將這些點連成線段組成閉合區域。

 //一堆點 獲得閉合一堆線段  static getSegments(points, closed = false) {    const list = []    for (let i = 1; i < points.length; i++) {      list.push(new Seg2d(points[i - 1], points[i]))    }    if (closed && !points[0].equal(points[points.length - 1])) {      list.push(new Seg2d(points[points.length - 1], points[0]))    }    return list  }

Closed 這個引數, 因為區域是滿足一個方向的。所以閉合區域 肯定是首尾相連的。

線段和線段求焦點

  1. 列方程求兩個直線的焦點
  2. 判斷每一條線段的兩個端點是否都在另一條線段的兩側, 是則求出兩條線段所在直線的交點, 否則不相交.

這裡我們用第二種方法去實現 :

第一步判斷兩個點是否在某條線段的兩側, 通常可採用投影法:

求出線段的法線向量, 然後把點投影到法線上, 最後根據投影的位置來判斷點和線段的關係. 見下圖

投影圖

點a和點b線上段cd法線上的投影如圖所示, 這時候我們還要做一次線段cd在自己法線上的投影(選擇點c或點d中的一個即可).
主要用來做參考.
圖中點a投影和點b投影在點c投影的兩側, 說明線段ab的端點線上段cd的兩側.

同理, 再判斷一次cd是否線上段ab兩側即可.

求法線 , 求投影 什麼的聽起來很複雜的樣子, 皆有公式可循:

const nx=b.y - a.y,       ny=a.x - b.x;  const normalLine = {  x: nx, y: ny };  

求點c在法線上的投影位置:

const dist= normalLine.x*c.x + normalLine.y*c.y;  

注意: 這裡的"投影位置"是一個標量, 表示的是到法線原點的距離, 而不是投影點的座標.

當我們把圖中 點a投影(distA),點b投影(distB),點c投影(distC) 都求出來之後, 就可以很容易的根據各自的大小判斷出相對位置.

distAdistBdistC 時, 兩條線段共線
distA==distB!=distC 時, 兩條線段平行
distA 和 distB 在distC 同側時, 兩條線段不相交.
distA 和 distB 在distC 異側時, 兩條線段是否相交需要再判斷點c點d與線段ab的關係.

這個優化 就優化在這裡, 回去做一層檢測, 然後再去求焦點, 求焦點用的也是固定公式。 我給出下面實現:

static lineLineIntersect(line1, line2) {    const a = line1.start    const b = line1.end    const c = line2.start    const d = line2.end    const interInfo = []    //線段ab的法線N1    const nx1 = b.y - a.y,      ny1 = a.x - b.x    //線段cd的法線N2    const nx2 = d.y - c.y,      ny2 = c.x - d.x    //兩條法線做叉乘, 如果結果為0, 說明線段ab和線段cd平行或共線,不相交    const denominator = nx1 * ny2 - ny1 * nx2    if (denominator == 0) {      return interInfo    }    //在法線N2上的投影    const distC_N2 = nx2 * c.x + ny2 * c.y    const distA_N2 = nx2 * a.x + ny2 * a.y - distC_N2    const distB_N2 = nx2 * b.x + ny2 * b.y - distC_N2    // 點a投影和點b投影在點c投影同側 (對點線上段上的情況,本例當作不相交處理);    if (distA_N2 * distB_N2 >= 0) {      return interInfo    }    //    //判斷點c點d 和線段ab的關係, 原理同上    //    //在法線N1上的投影    const distA_N1 = nx1 * a.x + ny1 * a.y    const distC_N1 = nx1 * c.x + ny1 * c.y - distA_N1    const distD_N1 = nx1 * d.x + ny1 * d.y - distA_N1    if (distC_N1 * distD_N1 >= 0) {      return interInfo    }    //計算交點座標    const fraction = distA_N2 / denominator    const dx = fraction * ny1,      dy = -fraction * nx1    interInfo.push(new Point2d(a.x + dx, a.y + dy))    return interInfo  }

這個ok 之後,我們去把任意多邊形的方法的是否在閉合區域內的方法去實現。

isPointInClosedRegion(event) {    const allSegs = Seg2d.getSegments(this.getDispersed(), true)    // 選取任意一條射線    const start = event.point    const xAxias = new Point2d(1, 0).multiplyScalar(800)    const end = start.clone().add(xAxias)    const anyRaySeg = new Seg2d(start, end)    let total = 0    allSegs.forEach((item) => {      const intersetSegs = Seg2d.lineLineIntersect(item, anyRaySeg)      total += intersetSegs.length    })    // 奇數在內部    if (total % 2 === 1) {      return true    }    return false  }

任意射線,我以滑鼠的點,作為起始點, 方向是X軸, 算出終點。 然後得到任意線段。去和所有線段 去求焦點。 統計焦點個數, 來確定是不是在內部。

OK, 這時候我們吧觸發事件的條件改寫下。

handleEvent = (name) => (event) => {    this.allShapes.forEach((shape) => {      // 獲取當前事件的所有監聽者      const listerns = shape.listenerMap.get(name)      if (        listerns &&        shape.isPointInClosedRegion(event)      ) {        listerns.forEach((listener) => listener(event))      }    })  }

這樣其實就已經實現了,在區域內部實現事件觸發了。 看下gif區域內部點選

一開始點選的是空白處,然後我分別點了 polygon 和 矩形 和圓形 ,看控制檯 你能看到結果。說明我們的演算法實現成功了。

阻止事件冒泡

這時候有同學又要問了,我點選兩個圖形相交的部分,我只想選中內部的, 外面的不想選中。 這是個很正常的需求,首先原生的event 肯定已經滿足不了我們了, 解決這個問題就是,分發到這個圖形的時候不去觸發的所有listeners。不就搞定了。所以我重寫了event,其實 也沒什麼,也就做了兩件事

  1. 第一件事就是將滑鼠的點 轉為 point2d
  2. 增加一個屬性isStopBubble 來阻止冒泡

程式碼如下:

 getNewEvent(event) {    const point = new Point2d(event.offsetX, event.offsetY)    return {      point,      isStopBubble: false,      ...event,    }  }

我這樣的實現的依據是 圖形的增加到場景是有序的。這裡和大家說下React 事件系統, 由於有Vdom的存在,所以他將事件監聽到 document 上, 然後再去按照順序,去收集所有的lsiteners。 事件的捕獲 和冒泡 其實 就是一個 順序 和倒敘的問題。 他是這麼去實現的。 他阻止合成事件冒泡, 就是合成事件有個e.stopPropagation() 。由於我們canvas 沒有dom這個概念,所以我們人為封裝了一個屬性,並且將event傳給每個圖形 有他們控制 是否阻止。看程式碼:

handleEvent = (name) => (event) => {    event = this.getNewEvent(event)    this.allShapes.forEach((shape) => {      // 獲取當前事件的所有監聽者      const listerns = shape.listenerMap.get(name)      if (        listerns &&        shape.isPointInClosedRegion(event)        && !event.isStopBubble      ) {        listerns.forEach((listener) => listener(event))      }    })  }

主要是加了個條件。我們來測試下:

沒阻止,我點選公共區域。

沒阻止冒泡

阻止冒泡, 程式碼如下:

circle.on(click, (event) => {  event.isStopBubble = true  console.log(event, 'circle')})rect.on(click, (event) => {  console.log(event, 'rect')})

如圖:

阻止冒泡

總結

本篇文章大概就是簡單的實現了canvas 的事件系統了,水平有限,能表達的就這麼多。如果有更好的歡迎補充學習和交流,文章有錯誤的歡迎指正。我是熱愛圖形的Fly,我們下次再見?啦。 最後覺得看完對你有幫助的話,點贊? 再走吧。 知識輸出不容易,我會持續持續輸出高質量文章的。

資源獲得

如果對你有幫助的話,可以關注公眾號【前端圖形】,回覆 【事件】 可以獲得全部原始碼。

相關文章