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
ey
- 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ículaspeedX
yspeedY
: 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ículasdraw()
: 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 modificadosattributeChangedCallback(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
yevent.y
: obtenemos las coordenadas x e y asociadas al eventocssObj.getPropertyValue("background-color")
: obtenemos el color de fondo del elementojs-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