Componente Web con Partículas en lienzo Canvas

Uso del contexto 2d de Canvas para crear animación de partículas dentro de un WebComponent con atributos editables desde HTML
Componente Web con Partículas en lienzo Canvas

Antes de empezar a escribir, recomendar un canal de youtube de Frank https://www.youtube.com/channel/UCEqc149iR-ALYkGM6TG-7vQ, que contiene muchos e interesantes vídeos sobre Canvas sin librerías, con javascript nativo. Están muy bien explicados desde 0 y aumentando complejidad

Arrancar proyecto con ParcelJS

Sobre la raiz del proyecto, con ParcelJS instalado (Yo tengo instalada la versión 1.12.5)

parcel index.html

Creación de partículas

La clase de utilidad Particle es encargada de crear un círculo, permitiendo definir su:

  • Su posicionamiento mediante x e y
  • Su color de relleno

Además, le pasamos por parámetro el contexto 2D ctx para usarlo desde la clase CanvasDraw

class Particle {
  constructor(ctx, x, y, fillStyle) {
    this.ctx = ctx
    this.x = x
    this.y = y
    this.fillStyle = fillStyle

    this.size = Math.random() * 16 + 1
    this.speedX = Math.random() * 10 - 5
    this.speedY = Math.random() * 10 - 5
    this.color = fillStyle
  }
  //...
}

Tenemos otros atributos que se dan valor y actualizan dentro de la misma clase:

  • size: tamaño inicial de la partícula
  • speedX y speedY: dirección del movimiento(*)

(*) Por anticipar lo que veremos más adelante. Lo que realmente se hace es actualizar la posición de x e y de la partícula en función de los valores de speedX y speedY y volver a pintarlo con requestAnimationFrame() dando la sensación de movimiento

Tenemos dos métodos accesibles desde fuera:

  • update(): actualiza las propiedades de las partículas
  • draw(): pinta las partículas
class Particle {
  //...
  update() {
    this.x += this.speedX
    this.y += this.speedY
    if (this.size > 0.2) this.size -= 0.2
  }
  draw() {
    this.ctx.fillStyle = this.fillStyle
    this.ctx.beginPath()
    this.ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
    this.ctx.fill()
  }
}

En el método update() modificamos la posición de la partícula y su tamaño

El método draw() sirve para (re) dibujar la partícula tras los cambios de sus propiedades

Creación del WebComponent

Definición de nuestra etiqueta

Creamos nuestra clase tipo PascalCase extendiendo de HTMLElement y se define customElements.define("canvas-draw", CanvasDraw);, teniendo el cuenta que el primer parámetro será el nombre de la etiqueta (con al menos un guión medio) y el segundo parámetro será el nombre de la clase (que tendrá toda la lógica)

class CanvasDraw extends HTMLElement {
  constructor() {
    super()
  }
}

customElements.define('canvas-draw', CanvasDraw)

Atributos del WebComponent

Se han usado cuatro atributos.

Dos de ellos, #particlesArray y #animating para almacenar la cantidad de partículas creadas y para bloquear/liberar la animación

Los otros dos atributos particles y maxDistanceJoinParticles para definir cuantas partículas tendrá el canvas y para unir las partículas mediante una línea cuando la distancía entre ellas no supera cierto valor.

Los dos últimos atributos comentados están inicializados con valor por defecto, pero pueden ser definidos otros valores desde la vista HTML. Esta situación, customizable desde HTML, requiere que lo especifiquemos en la implementación de los métodos:

  • static get observedAttributes(): incluimos en el array aquellos atributos que pudieran ser modificados
  • attributeChangedCallback(name, oldValue, newValue): asignamos al atributo su nuevo valor
class CanvasDraw extends HTMLElement {
  #particlesArray = []
  #animating = false
  particles = 40
  maxDistanceJoinParticles = 80

  static get observedAttributes() {
    return ['particles', 'max-distance-join-particles']
  }
  attributeChangedCallback(name, oldValue, newValue) {
    if (name === 'particles') {
      this.particles = newValue || 100
    } else if (name === 'max-distance-join-particles') {
      this.maxDistanceJoinParticles = newValue || 80
    }
  }

  //...
}

Definición del constructor y HTML del WebComponent

En en constructor de clase añadimos shadow en modo abierto this.attachShadow({ mode: "open" }). Iniciamos el canvas y el contexto 2D como null

Seleccionamos todos los elementos del DOM (con clase js-particles) que serán los encargados de iniciar las animaciones canvas

El método render se llama al final del constructor. Este método es el encargado de dar estilos al canvas y de añadirlo al DOM. También sacamos una referencia al contexto canvas para poder usarlo más adelante this.ctx = this.canvas.getContext("2d");

Sobre estilos, comentar que lo único importante es que el canvas está posicionado como fixed ocupando toda la pantalla, como bloque, sin color y anulando eventos click

class CanvasDraw extends HTMLElement {
  //...

  constructor() {
    super()
    this.attachShadow({ mode: 'open' })
    this.canvas = null
    this.ctx = null
    this.btns = document.querySelectorAll('.js-particles')

    this.render()
  }

  //...

  render() {
    const style = document.createElement('style')
    style.textContent = `
      canvas-draw {
        display: block;
        overflow: hidden;
        position: fixed;
        inset: 0;
        width: 100%;
        height: 100%;
        pointer-events: none;
      }
    `
    this.appendChild(style)

    this.canvas = document.createElement('canvas')
    this.shadowRoot.appendChild(this.canvas)
    this.ctx = this.canvas.getContext('2d')
  }

  //...
}

Registro de eventos del WebComponent

La animación se dispara cuando hacemos mousedown sobre aquellos elementos con clase js-particles. Añadimos y quitamos el listener en los métodos connectedCallback() y disconnectedCallback() respectivamente

Hemos creado la función auxiliar _handlerMouseDown(event) para que sea más fácil registrar y eliminar el evento. Este método se dispara si actualmente no existe ninguna animación de partículas. (Se trata de animaciones canvas, si lanzamos muchas animaciones podría consumir muchos recursos el navegador)

Obtenemos aquí tres datos para la definición de las partículas:

  • event.x y event.y: obtenemos las coordenadas x e y asociadas al evento
  • cssObj.getPropertyValue("background-color"): obtenemos el color de fondo del elemento js-particles sobre el que se hizo click

Creamos tantas partículas como se indicasen en su atributo this.particles y lo guardamos en un array this.#particlesArray para saber cuando todas las partículas desaparecerán

El método _animate() lo dejamos para comentarlo en el siguiente punto

class CanvasDraw extends HTMLElement {
  //...

  connectedCallback() {
    this.btns.forEach((btn) => {
      btn.addEventListener('mousedown', this._handlerMouseDown.bind(this))
    })
  }
  disconnectedCallback() {
    this.btns.forEach((btn) => {
      btn.removeEventListener('mousedown', this._handlerMouseDown.bind(this))
    })
  }

  //...

  _handlerMouseDown(event) {
    if (this.#animating) return
    this.#animating = true
    this._calculateCanvasSize()
    const cssObj = window.getComputedStyle(event.target, null)
    const bgColor = cssObj.getPropertyValue('background-color')
    for (let i = 0; i < this.particles; i++) {
      this.#particlesArray.push(
        new Particle(this.ctx, event.x, event.y, bgColor)
      )
    }
    this._animate()
  }

  _calculateCanvasSize() {
    this.canvas.width = this.clientWidth
    this.canvas.height = this.clientHeight
  }

  //...
}

Si has observado el código de arriba, habrás visto que este método _calculateCanvasSize() no lo he mencionado. Es para asignar el tamaño del canvas, haciéndolo justo en este punto, momento del mousedown, nos ahorramos tener que user eventos resize

Animaciones Canvas del WebComponent

El método _animate() es el encargado de generar la animación, se ejecuta 60fps ya que hace llamada a requestAnimationFrame(). Cada vez que entra se limpia el lienzo y se hace llamada a la función _handleParticles() para actualizar partículas que en seguida veremos. Esta animación no se para hasta que detectamos que el array de partículas this.#particlesArray ha quedado vacío, cuando ha quedado vacío cambiamos this.#animating = false a false para que se pueda iniciar nuevas animaciones y volvemos a limpiar el lienzo

El método _handleParticles recorre el array de partículas creadas y las actualiza haciendo llamada a los métodos update() y draw() de la clase Particles. Si el tamaño de las partículas es menor a uno dado (en nuestro caso es if (this.#particlesArray[i].size <= 0.2)) entonces lo quitamos del array. El bucle for interior es auxiliar para añadir algo más a la. En este caso, lo que añade es una línea que une aquellas partículas próximas entre sí, con el requisito de que su distancia sea menor a una dada this.maxDistanceJoinParticles (recuerda de más arriba, este era uno de los valores personalizables comoa tributos del WebComponent)

class CanvasDraw extends HTMLElement {
  //...

  _handleParticles() {
    for (let i = 0; i < this.#particlesArray.length; i++) {
      this.#particlesArray[i].update()
      this.#particlesArray[i].draw()

      for (let j = i; j < this.#particlesArray.length; j++) {
        const dx = this.#particlesArray[i].x - this.#particlesArray[j].x
        const dy = this.#particlesArray[i].y - this.#particlesArray[j].y
        const distance = Math.sqrt(dx * dx + dy * dy)
        if (distance < this.maxDistanceJoinParticles) {
          this.ctx.beginPath()
          this.ctx.strokeStyle = this.#particlesArray[i].color
          this.ctx.lineWidth = 0.2
          this.ctx.moveTo(this.#particlesArray[i].x, this.#particlesArray[i].y)
          this.ctx.lineTo(this.#particlesArray[j].x, this.#particlesArray[j].y)
          this.ctx.stroke()
          this.ctx.closePath()
        }
      }
      if (this.#particlesArray[i].size <= 0.2) {
        this.#particlesArray.splice(i, 1)
        i--
      }
    }
  }

  _animate() {
    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    this._handleParticles()
    if (this.#particlesArray.length > 0) {
      requestAnimationFrame(this._animate.bind(this))
    } else {
      this.#animating = false
      this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
    }
  }

  //...
}

Uso del WebComponente

Al inicio comentamos cual sería el nombre de la clases, y que además, podemos usar dos atributos para cambiar la cantidad de partículas y la distancia máxima para que se unan mediante una línea, esto es:

<canvas-draw particles="90" max-distance-join-particles="80"></canvas-draw>

Si vas a cambiar los valores de atributo, ten cuidado, ya que podría consumir muchos recursos. Recomiendo no subir los valores por encima de de 150

Codepen del WebComponent

En este PEN puede verse el WebComponente funcionando

Github del Webcomponent

En este repositorio puede verse el código del Webcomponente