Может ли холст также реализовать систему событий? ? ? ?

внешний интерфейс Canvas
Может ли холст также реализовать систему событий? ? ? ?

Это третий день моего участия в августовском испытании обновлений. Узнайте подробности мероприятия:  [Вызов августовского обновления]

предисловие

Всем привет! Я муха, которая любит графику.Обсуждала с фанатами в группе, как canvas реализует систему событий, и что дальше? На самом деле я очень заинтересован в этом сам Я видел много проектов реализации холста, таких как реализация интеллект-карт на холсте.xmind, холст реализуетинструменты рисования. Тогда неважно какой, на самом деле за canavs реализована система событий.К сожалению, эти исходники не являются открытым исходным кодом. Поэтому со страстью к обучению я ссылаюсь на некоторые статьи, чтобы реализовать простую систему событий. В этой статье вы можете узнать следующее 👇

  1. Как я иду на основе холстаСоберите базовый каркасиз
  2. Геометрические алгоритмы -Определить, находится ли точка внутри любого многоугольника
  3. как поступитьраспределение событийа такжепредотвращение всплытия событий

Эта статья обо мне. Добро пожаловать лайк, подписка, избранное.

Строительство основного каркаса

Графика

Первое, что я хочу сделать, это абстрагироваться от концепции. Подумайте об этом, холст — это, по сути, слой холста, а затем на холсте есть много графики, включая прямоугольники, круги и произвольно замкнутые многоугольники. ориентированной точки зрения, мы можем инкапсулировать базовый класс —shapeКаждое изображение отображается на холсте, поэтому должно быть одно

draw, а другой метод заключается в том, чтобы определить, находится ли точка мыши внутри текущего рисунка, о чем я расскажу позже. Тогда каждая графика имеет свои уникальные свойства, которые задаются в сочетании с 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 минуту, чтобы подумать, как рисовать прямоугольники и круги на холсте. По сути это два апи один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()

Это круг. Прелесть методов сохранения и восстановления в том, что, например, если я установлю красный цвет для круга, если я снова нарисую прямоугольник, прямоугольник также станет красным, что не поддается контролю. центр круга добавляется с радиусом и цветом заливки.

Прочитав круг, мы смотрим на прямоугольник.

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()

Свойства прямоугольника Точка в верхнем левом углу имеет длину и ширину. Хорошо, основная конструкция графики здесь завершена, давайте начнем создавать класс холста.

Холст класс

То, что в настоящее время делает класс холста, очень просто, инициализирует некоторые свойства. Во-первых, у него есть метод add() для добавления различной графики на холст. Добавлена ​​графика, каждая графика реализована внутриdrawметод. Это реализует операцию добавления графики на холст. Посмотрите прямо на код:

// 新建一个画布类
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)

Очень удобно писать код таким образом, он очень четкий и читабельность очень высокая вау

画布创建

Хорошо, кажется, что с кодом, который мы написали, проблем нет, давайте напишем чуть более сложный граф, замыкание, составленное из произвольных точек.polygon

ПОЛИГОН класс

Так же есть два метода: draw и isPointInClosedRegion.Для этого метода рисования атрибутом является куча 2d точек, первая точка это двигающаяся кисть🖌, а остальные точки вызывают canvaslineToМетоды. Тогда просто закройте область.

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В классе есть случайный метод, который передает длину и ширину холста. Для тех, кто не понял, посмотрите, что я писал ранее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)

Посмотрим на результаты:

三个图形

базовый класс ФОРМА

Когда я писал это, кто-то спросил, все эти три класса наследуют форму базового класса, какими общими возможностями обладает форма? Здесь начинается тема нашей статьи, то есть есть ли у каждой графики событие слушателя, существует множество типов событий. Под каждым типом должно быть много функций мониторинга, ОК, во-первых, это общая способность для всех, или то, что нужно всем, мы просто помещаем это в базовый класс, затем какую структуру данных мы используем для чего о хранении - такое значение ключа можно использовать с первого взгляда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])
    }
  }
}

В этом методе первым параметром является имя события, а вторым параметром — прослушиватель. Пока все в порядке, у события, соответствующего каждому графику, есть прослушиватель.

распределение событий

Этот раздел предназначен для передачи всех связанных с холстом событий каждой графике. Первый шаг, мы первые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')
})

事件系统点击

Я не знаю, нашли ли вы какие-либо проблемы. Хотя мы внедрили распределение событий, естьвопрос, я нажимаю в любом месте холста, он срабатывает, возможно, то, что я нажимаю, вообще не находится внутри графики, которую я рисую. Поэтому, когда мы распределяем событие, нам также необходимо оценить, находится ли щелчок мыши внутри закрытой области. Таким образом, каждая форма должна быть реализована внутриisPointInClosedRegionСюда.

реализация круга

Определить, находится ли точка внутри круга, на самом деле очень просто, в основном для сравненияСравните расстояние между точкой мыши и центром круга с радиусом, а тут можно судить Ха, это еще ничего. Перейдите непосредственно к коду:

const { center, radius } = this.props
return 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 пересечений слева от контрольной точки и 3 справа, все они нечетные, значит точка находится внутри полигона.

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
  }

Закрыл этот параметр, т.к. регион удовлетворяет одному направлению. Таким образом, замкнутая область должна быть соединена встык.

Линейный сегмент и линейный сегмент, чтобы найти фокус

  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) на рисунке, мы можем легко судить об относительном положении в соответствии с их соответствующими размерами.

Когда distA==distB==distC, два отрезка лежат на одной прямой. Если 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
  }

После этого давайте реализуем метод определения того, находится ли метод произвольного многоугольника в замкнутой области.

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, а конечная точка вычисляется. Затем получите любой отрезок. Перейти и все сегменты линии, чтобы найти фокус. Подсчитайте количество фокусных точек, чтобы определить, находится ли он внутри.

Хорошо, теперь давайте перепишем условия, запускающие событие.

handleEvent = (name) => (event) => {
    this.allShapes.forEach((shape) => {
      // 获取当前事件的所有监听者
      const listerns = shape.listenerMap.get(name)
      if (
        listerns &&
        shape.isPointInClosedRegion(event)
      ) {
        listerns.forEach((listener) => listener(event))
      }
    })
  }

Это действительно было реализовано, и срабатывание событий реализовано внутри области. посмотри на гифку区域内部点击

Сначала нажимал на пустое место, потом нажимал полигон и прямоугольник и круг соответственно, смотрим в консоль и можно увидеть результат. Это показывает, что наш алгоритм успешно реализован.

предотвращение всплытия событий

В это время некоторые студенты хотят еще раз спросить,Я нажимаю на ту часть, где пересекаются две фигуры, я хочу выбрать только внутреннюю, а не внешнюю. Это вполне нормальное требование.Во-первых, нативного события нам точно не хватит.Решение этой проблемы — не запускать все слушатели при его распределении по графу. Не понимаю. Вот я и переписал событие, по сути ничего, просто сделал две вещи

  1. Первое, что нужно сделать, это преобразовать точку мыши в point2d.
  2. Добавьте свойство isStopBubble, чтобы остановить всплывающую подсказку.

код показывает, как показано ниже:

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

В основе моей такой реализации лежит то, что добавление графики в сцену упорядочено. Расскажу о системе событий React, из-за существования Vdom он прослушивает событие на документе, а потом собирает всех лиситенеров по порядку. Захват и всплытие событий на самом деле является вопросом последовательности и ретроспективного кадра. Он так и сделал. Это предотвращает всплывание синтетических событий, то есть синтетические события имеют e.stopPropagation() . Поскольку в нашем холсте нет концепции dom, мы искусственно инкапсулируем свойство и передаем событие каждой графике, чтобы они могли контролировать блокировку. Посмотрите на код:

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')
})

Как показано на рисунке:

阻止冒泡

Суммировать

Эта статья, вероятно, является простой реализацией холста.система событийСейчас уровень ограничен, и выразить можно лишь ограниченное количество вещей. Если есть лучше добро пожаловать в дополнение к изучению и общению, прошу поправить меня, если в статье есть ошибки. Я Флай, который любит графику, увидимся в следующий раз👋. В конце концов, если вы чувствуете, что чтение полезно для вас, ставьте лайк 👍 и поехали. Вывод знаний дается непросто, и я буду продолжать выводить качественные статьи.

приобретение ресурсов

Если это поможет вам, вы можете подписаться на общедоступный номер[Внешняя графика],Отвечать【мероприятие】Весь исходный код доступен.