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
Sobre la raiz del proyecto, con ParcelJS instalado (Yo tengo instalada la versión 1.12.5)
parcel index.html
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
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);
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;
}
}
//...
}
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");
}
//...
}
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
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);
}
}
//...
}
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
En este PEN puede verse el WebComponente funcionando
En este repositorio puede verse el código del Webcomponente