UNPKG

text-particle

Version:
750 lines (732 loc) 24 kB
'use strict'; function invariant(value, msg = 'debugger point') { if (typeof value !== 'boolean' && !value) { console.error(msg); throw new Error('Unexpected empty value.'); } } function distance(x1, y1, x2, y2) { const x = Math.abs(x1 - x2); const y = Math.abs(y1 - y2); return Math.floor(Math.sqrt(x * x + y * y)); } /** * * @param t cost time * @param d duration time * @param p particle * @returns */ function ease(t, d, s, e) { if (t >= d) { return e; } const x = t / d; // const y = -x * x + 2 * x const y = -x * x + 2 * x; return s + (e - s) * y; } function isApproximateEqual(a, b) { return Math.abs(a - b) <= 1.0; } function useRAF(fn) { let raf = -1; const run = () => { fn(); raf = requestAnimationFrame(() => { run(); }); }; const cancel = () => { cancelAnimationFrame(raf); }; return [run, cancel]; } function transformHexStrToRGBA(color) { if (!color.startsWith('#')) { throw new Error('Error color style'); } color = color.substring(1); // ensure correct length if (color.length === 3) { const [r, g, b] = color; color = r + r + g + g + b + b; } if (color.length !== 6) { throw new Error('Error color style'); } const rgb = []; for (let i = 0; i < 6; i += 2) { rgb.push(Number.parseInt(color[i] + color[i + 1], 16)); } // A default to 255 rgb.push(255); return rgb; } function shallowEqual(obj1, obj2) { const keys = Object.keys(obj1); for (const key of keys) { if (obj1[key] !== obj2[key]) { return false; } } return true; } function shallowClone(obj) { return { ...obj }; } function filterRGBA(r, g, b, a) { return (r + g + b) > 0 && a > 0; } class Particle { x; y; r; c; static from(imageData, gap = 1, radius = 1, f) { gap = Math.max(1, gap); radius = Math.max(1, radius); const filter = f || filterRGBA; const { data, width, height } = imageData; const result = []; let r = 0, g = 0, b = 0, a = 0; let index = 0; let pre = 0; for (let i = 0; i < height; i += gap) { pre = i * width * 4; for (let j = 0; j < width; j += gap) { index = pre + j * 4; r = data[index]; g = data[index + 1]; b = data[index + 2]; a = data[index + 3]; if (filter(r, g, b, a)) { result.push(Particle.create(j, i, radius, [r, g, b, a])); } } } // console.log('particle count:', result.length) return result; } static create(x, y, r = 1, c = [0, 0, 0, 1]) { return new Particle(x, y, r, c); } static copyWithin(source, start = 0, end = source.length) { return source.copyWithin(start, end).map(s => s.clone()); } get nextX() { return this._nextX; } get nextY() { return this._nextY; } get preX() { return this._preX; } get preY() { return this._preY; } get arrived() { return this._nextX === this.x && this._nextY === this.y; } get color() { const [r, g, b, a] = this.c; return `rgba(${r}, ${g}, ${b}, ${a})`; } _nextX; _nextY; _preX; _preY; constructor(x, y, r, c) { this.x = x; this.y = y; this.r = r; this.c = c; this._nextX = this._preX = this.x; this._nextY = this._preY = this.y; } clone() { return Particle.create(this.x, this.y, this.r, this.c); } updateNext(x, y, r = this.r, c = this.c) { this._preX = this.x; this._preY = this.y; this._nextX = x; this._nextY = y; this.r = r; this.c = c; } update(x = this._nextX, y = this._nextY) { x = isApproximateEqual(x, this._nextX) ? this._nextX : x; y = isApproximateEqual(y, this._nextY) ? this._nextY : y; this.x = x; this.y = y; } } class Renderer { root; config; constructor(root, config) { this.root = root; this.config = config; } } const FRAGMENT_SHADER_SOURCE = `precision mediump float; varying vec4 v_color; void main() { gl_FragColor = v_color; }`; const VERTEX_SHADER_SOURCE = `attribute vec2 a_position; attribute vec4 a_color; varying vec4 v_color; uniform vec2 u_resolution; uniform float u_point_size; void main() { vec2 clipSpace = a_position / u_resolution * 2.0 - 1.0; gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1); gl_PointSize = u_point_size; v_color = a_color / vec4(255.0, 255.0, 255.0, 255.0); }`; function createShader(gl, type, source) { const shader = gl.createShader(type); invariant(shader); gl.shaderSource(shader, source); gl.compileShader(shader); const success = gl.getShaderParameter(shader, gl.COMPILE_STATUS); if (success) { return shader; } const errorInfo = gl.getShaderInfoLog(shader) || ''; gl.deleteShader(shader); throw new Error(errorInfo); } function createProgram(gl, vs, fs) { const program = gl.createProgram(); invariant(program); gl.attachShader(program, vs); gl.attachShader(program, fs); gl.linkProgram(program); const success = gl.getProgramParameter(program, gl.LINK_STATUS); if (success) { return program; } const errorInfo = gl.getProgramInfoLog(program) || ''; gl.deleteProgram(program); throw new Error(errorInfo); } /** * * @param gl * @param program * @param points * @returns */ function setAttributeBuffer(gl, buffer, points) { gl.bindBuffer(gl.ARRAY_BUFFER, buffer.buffer); gl.bufferData(gl.ARRAY_BUFFER, points, gl.STATIC_DRAW); gl.vertexAttribPointer(buffer.location, buffer.readSize, gl.FLOAT, false, 0, 0); gl.enableVertexAttribArray(buffer.location); } class WebGLRenderer extends Renderer { root; config; gl; program; pointsBuffer; colorBuffer; constructor(root, config) { super(root, config); this.root = root; this.config = config; const gl = this.root.getContext('webgl'); invariant(gl, 'Not found webgl context.'); this.gl = gl; const vs = createShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER_SOURCE); const fs = createShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER_SOURCE); this.program = createProgram(gl, vs, fs); invariant(this.program); gl.useProgram(this.program); const pb = gl.createBuffer(); invariant(pb, 'Point buffer creation failed.'); const cb = gl.createBuffer(); invariant(cb, 'Color buffer creation failed.'); this.pointsBuffer = pb; this.colorBuffer = cb; } resize() { const { program, gl } = this; const resolutionLocation = gl.getUniformLocation(program, 'u_resolution'); gl.uniform2f(resolutionLocation, this.root.width, this.root.height); const pointSizeLocation = gl.getUniformLocation(program, 'u_point_size'); gl.uniform1f(pointSizeLocation, this.config.particleRadius || 1); gl.viewport(0, 0, this.root.width, this.root.height); } render(particles, config) { this.config = config; const { program, gl } = this; const len = particles.length; const _positions = new Array(len * 2); const _colors = new Array(len * 4); let configColor = []; let useConfigColor = false; if (this.config.color) { useConfigColor = true; configColor = transformHexStrToRGBA(this.config.color); } let pIndex = 0, cIndex = 0; particles.forEach((p) => { _positions[pIndex++] = p.x; _positions[pIndex++] = p.y; const c = useConfigColor ? configColor : p.c; for (let i = 0; i < c.length; i++) { _colors[cIndex++] = c[i]; } }); const positions = new Float32Array(_positions); const colors = new Float32Array(_colors); setAttributeBuffer(gl, { buffer: this.pointsBuffer, location: gl.getAttribLocation(program, 'a_position'), readSize: 2 }, positions); setAttributeBuffer(gl, { buffer: this.colorBuffer, location: gl.getAttribLocation(program, 'a_color'), readSize: 4 }, colors); gl.drawArrays(gl.POINTS, 0, particles.length); } } class CanvasRenderer extends Renderer { root; config; ctx; constructor(root, config) { super(root, config); this.root = root; this.config = config; const canvasCtx = this.root.getContext('2d'); invariant(canvasCtx, 'not found canvas 2d context'); this.ctx = canvasCtx; } resize() { } render(particles, config) { this.config = config; this.ctx.clearRect(0, 0, this.root.width, this.root.height); if (this.config.color) { this.batchDraw(particles, this.config.color); } else { this.singleDraw(particles); } } singleDraw(particles) { particles.forEach(p => { this._singleDraw(p); }); } _singleDraw(p, stroke = false) { const { ctx } = this; const { x, y, r, color } = p; this.updateDrawStyle(color); ctx.moveTo(x, y); ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); if (stroke) { ctx.stroke(); } else { ctx.fill(); } } updateDrawStyle(color) { const { ctx } = this; if (ctx.fillStyle !== color) { ctx.fillStyle = color; } if (ctx.strokeStyle !== color) { ctx.strokeStyle = color; } } /** * We can improve drawing performance if the user sets the color */ batchDraw(particles, color) { const { ctx } = this; this.updateDrawStyle(color); ctx.beginPath(); particles.forEach(p => { if (this.config.particleRadius <= 1) { // When the particle radius is less than 1, we can replace the circle with a rectangle ctx.rect(p.x, p.y, p.r * 2, p.r * 2); } else { ctx.roundRect(p.x, p.y, p.r * 2, p.r * 2, p.r); } }); ctx.fill(); } } const defaultConfig = { enableWebGL: false, source: '', color: '', particleRadius: 1, particleGap: 1, // limit the gap and radius enableContinuousEasing: true, showMouseCircle: true, moveProportionPerFrame: 30, offsetX: 0, offsetY: 0, disableCache: false }; function mergeConfig(source, config) { const template = shallowClone(source); Object.assign(template, { enableWebGL: config.enableWebGL ?? source.enableWebGL, source: config.source ?? source.source, color: config.color ?? source.color, // limit the gap and radius particleRadius: config.particleRadius ?? source.particleRadius, particleGap: config.particleGap ?? source.particleGap, enableContinuousEasing: config.enableContinuousEasing ?? source.enableContinuousEasing, showMouseCircle: config.showMouseCircle ?? source.showMouseCircle, moveProportionPerFrame: config.moveProportionPerFrame ?? source.moveProportionPerFrame, offsetX: config.offsetX ?? source.offsetX, offsetY: config.offsetY ?? source.offsetY, disableCache: config.disableCache ?? source.disableCache, pixelFilter: config.pixelFilter ?? source.pixelFilter }); return template; } class ParticleEffect { root; renderer; canvas = document.createElement('canvas'); isRendering = false; cacheMap = new Map(); unBindMouseEventCallback = null; lastAnimationBeginTime = 0; animationTime = 2000; particles = []; mouseParticle = null; _config; constructor(root, config) { this.root = root; if (root instanceof HTMLCanvasElement) { this.canvas = root; } else { root.appendChild(this.canvas); } const { clientHeight, clientWidth } = root; this.canvas.width = clientWidth; this.canvas.height = clientHeight; this._config = mergeConfig(defaultConfig, config); if (this._config.enableWebGL) { this.renderer = new WebGLRenderer(this.canvas, this._config); } else { this.renderer = new CanvasRenderer(this.canvas, this._config); } if (this._config.showMouseCircle) { this.enableMouseListener(); } else { this.disableMouseListener(); } } destroy() { this.disableMouseListener(); this.cacheMap.clear(); } /** * * @param newSource * @param time this option will be disabled if 'enableContinuousEasing' is set to true * @returns */ async transitionTo(newSource, time, config = {}) { this._config = mergeConfig(this._config, config); if (!this.isRendering) { this.render(newSource); return; } if (this._config.source === newSource) { return; } this._config.source = newSource; this.animationTime = time; const newParticles = await this.generateParticles(newSource); if (!this.particles.length) { throw new Error('Particle generate error, please check the config.'); } const oldLen = this.particles.length; const newLen = newParticles.length; if (oldLen < newLen) { const difference = newLen - oldLen; const extra = []; for (let i = 0; i < difference; i++) { extra.push(this.particles[i % oldLen].clone()); } this.particles = this.particles.concat(extra); } else if (oldLen > newLen) { this.particles.splice(0, oldLen - newLen); } const len = this.particles.length; newParticles.sort(() => Math.random() > 0.5 ? 1 : -1); for (let i = 0; i < len; i++) { const newParticle = newParticles[i]; this.particles[i].updateNext(newParticle.x, newParticle.y, newParticle.r, newParticle.c); } // Be sure to record the time here, because the await expression takes time this.lastAnimationBeginTime = Date.now(); } resize() { if (!(this.root instanceof HTMLCanvasElement)) { this.canvas.width = this.root.clientWidth; this.canvas.height = this.root.clientHeight; } // render will auto call resize // resize need to rebuild particle this.cacheMap.clear(); } async render(source) { this.renderer.resize(); // Load particles first if (source && source !== this._config.source) { this._config.source = source; this.particles = await this.generateParticles(this._config.source); } if (!this._config.source) { throw new Error('Render need config source first!'); } if (!this.particles.length) { this.particles = await this.generateParticles(this._config.source); } const [render] = useRAF(() => { const costTime = Date.now() - this.lastAnimationBeginTime; if (this._config.enableContinuousEasing) { this.particles.forEach(p => this.updateParticleContinuous(p)); } else { this.particles.forEach(p => this.updateParticleEase(costTime, p)); } this.renderer.render(this.particles, this._config); }); if (!this.isRendering) { this.isRendering = true; render(); } } async generateParticles(source) { throw new Error('generateParticles need to be implemented'); } enableMouseListener() { const onMousemove = (event) => { if (!this.mouseParticle) { this.mouseParticle = Particle.create(-100, -100, 20, [255, 255, 255, 255]); } // to update unstoppable particle const rect = this.canvas.getBoundingClientRect(); const x = event.clientX - rect.left; const y = event.clientY - rect.top; this.mouseParticle.updateNext(x, y); this.mouseParticle.update(); }; const onMouseLeave = () => { this.mouseParticle = null; }; this.canvas.addEventListener('mousemove', onMousemove); this.canvas.addEventListener('mouseleave', onMouseLeave); this.unBindMouseEventCallback = () => { this.mouseParticle = null; if (this.canvas) { this.canvas.removeEventListener('mousemove', onMousemove); this.canvas.removeEventListener('mouseleave', onMouseLeave); } }; } disableMouseListener() { this.unBindMouseEventCallback?.(); } updateParticleEase(costTime, p) { const x = ease(costTime, this.animationTime, p.preX, p.nextX); const y = ease(costTime, this.animationTime, p.preY, p.nextY); p.update(x, y); } updateParticleContinuous(p) { // velocity of the remain movement let vx = ((p.nextX - p.x) / this._config.moveProportionPerFrame); let vy = ((p.nextY - p.y) / this._config.moveProportionPerFrame); if (this._config.showMouseCircle && this.mouseParticle) { // avoid mouse move const { x, y, r } = this.mouseParticle; const dis = distance(x, y, p.x, p.y); if (dis < r + 10) { const A = Math.atan2(p.y - y, p.x - x); let reverseV = 2 * (r / dis); reverseV = Math.min(1000, reverseV); reverseV = Math.max(1, reverseV); const reverseVx = Math.cos(A) * reverseV; const reverseVy = Math.sin(A) * reverseV; vx += reverseVx; vy += reverseVy; } } // apply change in this frame p.update(p.x + vx, p.y + vy); } } class ImageParticle extends ParticleEffect { autoFit = false; imageCache = new Map(); constructor(root, config) { super(root, config); this.updateConfig(config); } updateConfig(config) { this.autoFit = config.autoFit ?? this.autoFit; } transitionTo(newSource, time = 2000, config = {}) { this.updateConfig(config); return super.transitionTo(newSource, time, config); } async generateParticles(source) { const old = this.cacheMap.get(source); if (old && shallowEqual(old.config, this._config)) { return old.particles; } const tempCanvas = document.createElement('canvas'); const { width, height } = this.canvas; tempCanvas.width = width; tempCanvas.height = height; let image; const oldImage = this.imageCache.get(source); if (oldImage) { image = oldImage; } else { image = new Image(); image.crossOrigin = 'anonymous'; const loadPromise = new Promise((resolve, reject) => { image.onload = (ev) => { resolve(ev); }; image.onerror = (err) => { reject(err); }; }); image.src = source; await loadPromise; } // Need to grayscale, but not yet const { width: imageWidth, height: imageHeight } = image; const config = this._config; let drawWidth = imageWidth; let drawHeight = imageHeight; let offsetX = config.offsetX; let offsetY = config.offsetY; if (this.autoFit) { const scaleW = width / imageWidth; const scaleH = height / imageHeight; const scale = Math.min(scaleW, scaleH); const scaledWidth = Math.floor(imageWidth * scale); const scaledHeight = Math.floor(imageHeight * scale); offsetX = Math.floor(Math.abs(width - scaledWidth) / 2); offsetY = Math.floor(Math.abs(height - scaledHeight) / 2); drawWidth = scaledWidth; drawHeight = scaledHeight; } const ctx = tempCanvas.getContext('2d'); ctx.drawImage(image, offsetX, offsetY, drawWidth, drawHeight); const tempImageData = ctx.getImageData(0, 0, width, height); const newParticles = Particle.from(tempImageData, config.particleGap, config.particleRadius, config.pixelFilter); if (!this._config.disableCache) { this.cacheMap.set(source, { config: shallowClone(config), particles: newParticles.map(p => p.clone()) }); this.imageCache.set(source, image); } return newParticles; } } class TextParticle extends ParticleEffect { font = 'bold 60px Arial'; textAlign = 'center'; constructor(root, config) { super(root, config); this.updateConfig(config); } updateConfig(config) { this.font = config.font ?? this.font; this.textAlign = config.textAlign ?? this.textAlign; } transitionTo(newSource, time = 2000, config = {}) { this.updateConfig(config); return super.transitionTo(newSource, time, config); } async generateParticles(source) { const old = this.cacheMap.get(source); if (old && shallowEqual(old.config, this._config)) { return old.particles; } const tempCanvas = document.createElement('canvas'); const { width, height } = this.canvas; tempCanvas.width = width; tempCanvas.height = height; // Need to grayscale, but not yet const ctx = tempCanvas.getContext('2d'); const config = this._config; ctx.fillStyle = config.color || '#FAF0E6'; ctx.font = this.font; ctx.textAlign = this.textAlign; ctx.textBaseline = 'middle'; await document.fonts.load(ctx.font); let x = 0; let y = height / 2; if (this.textAlign === 'center') { x = width / 2; } else if (this.textAlign === 'right') { x = width; } else { x = 0; } ctx.fillText(source, Math.floor(x), Math.floor(y)); const tempImageData = ctx.getImageData(0, 0, width, height); const newParticles = Particle.from(tempImageData, config.particleGap, config.particleRadius, config.pixelFilter); if (!this._config.disableCache) { this.cacheMap.set(source, { config: shallowClone(config), particles: newParticles.map(p => p.clone()) }); } return newParticles; } } exports.CanvasRenderer = CanvasRenderer; exports.ImageParticle = ImageParticle; exports.Particle = Particle; exports.ParticleEffect = ParticleEffect; exports.Renderer = Renderer; exports.TextParticle = TextParticle; exports.WebGLRenderer = WebGLRenderer; exports.createProgram = createProgram; exports.createShader = createShader; exports.distance = distance; exports.ease = ease; exports.invariant = invariant; exports.isApproximateEqual = isApproximateEqual; exports.setAttributeBuffer = setAttributeBuffer; exports.shallowClone = shallowClone; exports.shallowEqual = shallowEqual; exports.transformHexStrToRGBA = transformHexStrToRGBA; exports.useRAF = useRAF; //# sourceMappingURL=main.js.map