rain-char
Version:
A lightweight JavaScript library that creates a 'Matrix-style' falling character effect with depth. Customize the font, colors, character range, and animation speed for dynamic visual effects on your webpage.
355 lines (298 loc) • 10.9 kB
JavaScript
/**
* Raining characters effect with depth and customizability https://m-sarabi.github.io/rain-char/
*
* Copyright (c) 2024 Mohammad Sarabi <https://m-sarabi.ir>
* Released under MIT license
*/
'use strict';
class RainChar {
/**
* @param {Object} options Configuration options for the rain effect.
* @param {string} [options.font='monospace'] The font used for raining characters.
* @param {[number, number]} [options.charSize=[10, 40]] The upper and lower limit for the font size.
* @param {[number, number] | [number, number][]} [options.charRange=[0x0021, 0x007e]] The range of Unicode character codes to be used.
* @param {string} [options.bg='black'] Background color.
* @param {string} [options.fg='limegreen'] Font color.
* @param {string} [options.id] ID of the canvas element.
* @param {number} [options.fps=30] Max frames per second.
* @param {number} [options.densityFactor=10] Defines how dense the rain falls; lower value means more characters.
* @param {number} [trailMultiplier=1] Defines the length of the trail; lower value means longer trail.
* @param {number} [charSpacing=1] Defines the gap between characters; lower value means less gap.
* @param {number} [charChangeFreq=1] Defines the frequency of character change; lower value means less frequent character change. Must be between 0 and 1.
* @param {string} [options.parentId] The ID of the parent element. If defined, the canvas will be appended to that element as a child.
*/
constructor(
{
font = 'monospace',
charSize = [10, 40],
charRange = [0x0021, 0x007e],
bg = 'black',
fg = 'limegreen',
id,
fps = 30,
densityFactor = 10,
trailMultiplier = 1,
charSpacing = 1,
charChangeFreq = 1,
preRender = false,
parentId,
} = {}) {
this._font = font;
this._charSize = charSize.sort((a, b) => a - b);
this._charRange = charRange.sort((a, b) => a - b);
this._bg = bg;
this._fg = fg;
this._isPaused = false;
this._fps = fps || 40;
this._densityFactor = densityFactor || 4;
this._trailMultiplier = trailMultiplier || 1;
this._charSpacing = charSpacing || 1;
this._charChangeFreq = Math.max(0, Math.min(1, charChangeFreq)) || 1;
this._preRender = preRender;
this._getCharCodes();
this._initializeCanvas(id, parentId);
this._initializeProperties();
this._setupResizeObserver();
if (this._preRender) {
this._initCharCache();
}
this._play = this._play.bind(this);
}
_initCharCache() {
this._charCache = new Map();
for (let charCode of this._charCodes) {
const char = String.fromCodePoint(charCode);
for (let size = this._charSize[0]; size < this._charSize[1] + 1; size++) {
this._charCache.set(`${char}__${size}`, this._createCharImage(char, size));
}
}
}
_createCharImage(char, size) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = size * 1.2;
canvas.height = size * 1.2;
ctx.fillStyle = this._fg;
ctx.font = `${size}px ${this._font}`;
ctx.textBaseline = 'top';
ctx.fillText(char, 0, 0);
return canvas;
}
_getCharImage(char, size) {
const key = `${char}__${size}`;
if (this._charCache.has(key)) {
window.mapLen = this._charCache.size;
return this._charCache.get(key);
}
const canvas = this._createCharImage(char, size);
this._charCache.set(key, canvas);
return canvas;
}
_initializeCanvas(id, parentId) {
this._canvas = document.createElement('canvas');
if (id) this._canvas.setAttribute('id', id);
this._ctx = this._canvas.getContext('2d');
this._size = [this._canvas.offsetWidth, this._canvas.offsetHeight];
const parentElement = parentId ? document.getElementById(parentId) : document.body;
parentElement.appendChild(this._canvas);
this._ctx.fillStyle = this._bg;
this._ctx.fillRect(0, 0, this._canvas.width, this._canvas.height);
}
_initializeProperties() {
this._lastFrameTime = 0;
this._particles = [];
}
_setupResizeObserver() {
new ResizeObserver(this._onResize.bind(this)).observe(this._canvas);
}
_newParticle() {
return {
x: Math.random() * this._size[0],
y: -Math.random() * this._size[1] * 2,
size: this._getRandomDistance(),
char: this._getRandomChar(),
};
}
_updateParticles() {
this._particles.forEach(particle => {
if (particle.y > this._size[1]) {
Object.assign(particle, this._newParticle());
} else {
particle.y += particle.size * this._charSpacing;
}
});
}
_onResize() {
const oldSize = [this._canvas.width, this._canvas.height];
this._size = [this._canvas.offsetWidth, this._canvas.offsetHeight];
const tempCanvas = document.createElement('canvas');
tempCanvas.width = oldSize[0];
tempCanvas.height = oldSize[1];
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(this._canvas, 0, 0);
this._canvas.width = this._size[0];
this._canvas.height = this._size[1];
this._ctx.fillStyle = this._bg;
this._ctx.fillRect(0, 0, this._size[0], this._size[1]);
this._ctx.drawImage(tempCanvas, 0, 0);
this._adjustParticleCount();
}
_adjustParticleCount() {
const avgCharSize = (this._charSize[0] + this._charSize[1]) / 3;
this.rainCount = Math.floor(this._size[0] * this._size[1] / (avgCharSize ** 2 * this._densityFactor));
while (this._particles.length > this.rainCount) {
this._particles.pop();
}
while (this._particles.length < this.rainCount) {
this._particles.push(this._newParticle());
}
}
_play(timestamp = 0) {
if (this._isPaused) return;
this.elapsed = timestamp - this._lastFrameTime;
if (this.elapsed >= this._frameInterval) {
this._lastFrameTime = timestamp;
this._clearCanvas();
this._updateParticles();
this._drawParticles();
}
requestAnimationFrame(this._play);
}
_clearCanvas() {
this._ctx.globalAlpha = 0.25 * this._trailMultiplier;
this._ctx.fillStyle = this._bg;
this._ctx.fillRect(0, 0, this._size[0], this._size[1]);
this._ctx.globalAlpha = 1;
}
_drawParticles() {
this._ctx.fillStyle = this._fg;
this._particles.forEach(particle => {
if (Math.random() < this._charChangeFreq) particle.char = this._getRandomChar();
if (this._preRender) {
const img = this._getCharImage(particle.char, particle.size);
this._ctx.drawImage(img, particle.x, particle.y);
} else {
this._ctx.font = `${particle.size}px ${this._font}`;
this._ctx.fillText(particle.char, particle.x, particle.y);
}
});
}
_getCharCodes() {
if (Array.isArray(this._charRange[0])) {
this._charCodes = this._charRange.map(range => [...Array(range[1] - range[0] + 1).keys()].map(x => x + range[0])).flat();
} else {
this._charCodes = [...Array(this._charRange[1] - this._charRange[0] + 1).keys()].map(x => x + this._charRange[0]);
}
}
_getRandomChar() {
return String.fromCodePoint(this._charCodes[Math.floor(Math.random() * this._charCodes.length)]);
}
_getRandomDistance() {
const random = Math.random();
const biasedRandom = random ** 2;
return Math.floor(biasedRandom * (this._charSize[1] - this._charSize[0] + 1)) + this._charSize[0];
}
/**
* Fresh starts the effect animation. It also acts as a restart.
*
* @return {void} No return value
*/
start() {
this._isPaused = false;
this._particles = [];
this._onResize();
this._frameInterval = 1000 / this._fps;
this._play();
}
/**
* Toggles the paused state of the animation.
*
* @return {void} No return value
*/
pause() {
this._isPaused = !this._isPaused;
if (!this._isPaused) {
this._play();
}
}
/**
* Stops the effect and clears the canvas.
*
* @return {void} No return value
*/
stop() {
this._isPaused = true;
this._ctx.clearRect(0, 0, ...this._size);
}
// Setters
set font(font) {
this._font = font;
}
set charSize(charSize) {
this._charSize = charSize;
this._adjustParticleCount();
}
set charRange(charRange) {
this._charRange = charRange;
this._getCharCodes();
}
set bg(bg) {
this._bg = bg;
}
set fg(fg) {
this._fg = fg;
}
set fps(fps) {
this._fps = fps;
this._frameInterval = 1000 / this._fps;
}
set densityFactor(densityFactor) {
this._densityFactor = densityFactor;
this._adjustParticleCount();
}
set trailMultiplier(trailMultiplier) {
this._trailMultiplier = trailMultiplier;
}
set charSpacing(charSpacing) {
this._charSpacing = charSpacing;
}
set charChangeFreq(charChangeFreq) {
this._charChangeFreq = charChangeFreq;
}
set preRender(state) {
if (state && !this._preRender) {
this._initCharCache();
}
}
// Getters
get font() {
return this._font;
}
get charSize() {
return this._charSize;
}
get charRange() {
return this._charRange;
}
get bg() {
return this._bg;
}
get fg() {
return this._fg;
}
get fps() {
return this._fps;
}
get densityFactor() {
return this._densityFactor;
}
get trailMultiplier() {
return this._trailMultiplier;
}
get charSpacing() {
return this._charSpacing;
}
get charChangeFreq() {
return this._charChangeFreq;
}
}