UNPKG

canvasparticles-js

Version:

In an HTML canvas, a bunch of interactive particles connected with lines when they approach each other.

659 lines (656 loc) 30.3 kB
'use strict'; // Copyright (c) 2022–2026 Kyle Hoeckman, MIT License // https://github.com/Khoeckman/canvasparticles-js/blob/main/LICENSE const TWO_PI = 2 * Math.PI; /** Extremely fast, simple 32‑bit PRNG */ function Mulberry32(seed) { let state = seed >>> 0; return { next() { let t = (state + 0x6d2b79f5) | 0; state = t; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }, }; } // Mulberry32 is ±392% faster than Math.random() // Benchmark: https://jsbm.dev/muLCWR9RJCbmy // Spectral test: /demo/mulberry32.html const prng = Mulberry32(Math.random() * 2 ** 32).next; class CanvasParticles { static version = "4.3.2"; static MAX_DT = 1000 / 50; // milliseconds between updates @ 50 FPS static BASE_DT = 1000 / 60; // milliseconds between updates @ 60 FPS /** Defines mouse interaction types with the particles */ static interactionType = Object.freeze({ NONE: 0, // No mouse interaction SHIFT: 1, // Visual displacement only MOVE: 2, // Actual particle movement (default) }); /** Defines how the particles are auto-generated */ static generationType = Object.freeze({ MANUAL: 0, // Never auto-generate particles NEW: 1, // Generate particles from scratch MATCH: 2, // Add or remove particles to match new count (default) }); /** Observes canvas elements entering or leaving the viewport to start/stop animation */ static canvasIntersectionObserver = new IntersectionObserver((entries) => { for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const canvas = entry.target; const instance = canvas.instance; // The CanvasParticles class instance bound to this canvas if (!instance.options?.animation) return; if ((canvas.inViewbox = entry.isIntersecting)) instance.option.animation?.startOnEnter && instance.start({ auto: true }); else instance.option.animation?.stopOnLeave && instance.stop({ auto: true, clear: false }); } }, { rootMargin: '-1px', }); static canvasResizeObserver = new ResizeObserver((entries) => { // Seperate for loops is very important to prevent huge forced reflow overhead // First read all canvas rects at once for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const canvas = entry.target; canvas.instance.updateCanvasRect(); } // Then resize all canvases at once for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const canvas = entry.target; canvas.instance.resizeCanvas(); } }); /** Helper functions for options parsing */ static defaultIfNaN = (value, defaultValue) => isNaN(+value) ? defaultValue : +value; static parseNumericOption = (name, value, defaultValue, clamp) => { if (value == undefined) return defaultValue; const { min = -Infinity, max = Infinity } = clamp ?? {}; if (value < min) { console.warn(new RangeError(`option.${name} was clamped to ${min} as ${value} is too low`)); } else if (value > max) { console.warn(new RangeError(`option.${name} was clamped to ${max} as ${value} is too high`)); } return CanvasParticles.defaultIfNaN(Math.min(Math.max(value ?? defaultValue, min), max), defaultValue); }; canvas; ctx; enableAnimating = false; isAnimating = false; lastAnimationFrame = 0; particles = []; hasManualParticles = false; // set to true once @public createParticle() is used clientX = Infinity; clientY = Infinity; mouseX = Infinity; mouseY = Infinity; width; height; offX; offY; option; color; /** * Initialize a CanvasParticles instance * @param selector - Canvas element or CSS selector * @param options - Configuration object for particles (https://github.com/Khoeckman/canvasParticles?tab=readme-ov-file#options) */ constructor(selector, options = {}) { let canvas; // Find the HTMLCanvasElement and assign it to `this.canvas` if (selector instanceof HTMLCanvasElement) canvas = selector; else { if (typeof selector !== 'string') throw new TypeError('selector is not a string and neither a HTMLCanvasElement itself'); canvas = document.querySelector(selector); if (!(canvas instanceof HTMLCanvasElement)) throw new Error('selector does not point to a canvas'); } this.canvas = canvas; this.canvas.instance = this; // Circular assignment to find the instance bound to this canvas this.canvas.inViewbox = true; // Get 2d drawing methods const ctx = this.canvas.getContext('2d'); if (!ctx) throw new Error('failed to get 2D context from canvas'); this.ctx = ctx; this.options = options; // Uses setter CanvasParticles.canvasIntersectionObserver.observe(this.canvas); CanvasParticles.canvasResizeObserver.observe(this.canvas); // Setup event handlers this.resizeCanvas = this.resizeCanvas.bind(this); this.handleMouseMove = this.handleMouseMove.bind(this); this.handleScroll = this.handleScroll.bind(this); this.updateCanvasRect(); this.resizeCanvas(); window.addEventListener('mousemove', this.handleMouseMove, { passive: true }); window.addEventListener('scroll', this.handleScroll, { passive: true }); } /* @public Update the canvas bounding rectangle and mouse position relative to it */ updateCanvasRect() { const { top, left, width, height } = this.canvas.getBoundingClientRect(); this.canvas.rect = { top, left, width, height }; } handleMouseMove(event) { if (!this.enableAnimating) return; this.clientX = event.clientX; this.clientY = event.clientY; if (!this.isAnimating) return; this.updateMousePos(); } handleScroll() { if (!this.enableAnimating) return; this.updateCanvasRect(); if (!this.isAnimating) return; this.updateMousePos(); } /** @public Update mouse coordinates */ updateMousePos() { const { top, left } = this.canvas.rect; this.mouseX = this.clientX - left; this.mouseY = this.clientY - top; } /** @public Resize the canvas and update particles accordingly */ resizeCanvas() { const width = (this.canvas.width = this.canvas.rect.width); const height = (this.canvas.height = this.canvas.rect.height); // Hide the mouse when resizing because it must be outside the viewport to do so this.mouseX = Infinity; this.mouseY = Infinity; this.width = Math.max(width + this.option.particles.connectDist * 2, 1); this.height = Math.max(height + this.option.particles.connectDist * 2, 1); this.offX = (width - this.width) / 2; this.offY = (height - this.height) / 2; const generationType = this.option.particles.generationType; if (generationType !== CanvasParticles.generationType.MANUAL) { if (generationType === CanvasParticles.generationType.NEW || this.particles.length === 0) this.newParticles(); else if (generationType === CanvasParticles.generationType.MATCH) this.matchParticleCount({ updateBounds: true }); } if (this.isAnimating) this.#render(); } /** @private Update the target number of particles based on the current canvas size and `option.particles.ppm`, capped at `option.particles.max`. */ #targetParticleCount() { // Amount of particles to be created let particleCount = Math.round((this.option.particles.ppm * this.width * this.height) / 1_000_000); particleCount = Math.min(this.option.particles.max, particleCount); if (!isFinite(particleCount)) throw new RangeError('particleCount must be finite'); return particleCount | 0; } /** @public Remove existing particles and generate new ones */ newParticles({ keepAuto = false, keepManual = true } = {}) { const particleCount = this.#targetParticleCount(); if (this.hasManualParticles && (keepAuto || keepManual)) { this.particles = this.particles.filter((particle) => (keepAuto && !particle.isManual) || (keepManual && particle.isManual)); this.hasManualParticles = this.particles.length > 0; } else { this.particles = []; } for (let i = 0; i < particleCount; i++) this.#createParticle(); } /** @public Adjust particle array length to match `option.particles.ppm` */ matchParticleCount({ updateBounds = false } = {}) { const particleCount = this.#targetParticleCount(); if (this.hasManualParticles) { const pruned = []; let autoCount = 0; // Keep manual particles while pruning automatic particles that exceed `particleCount` // Only count automatic particles towards `particledCount` for (const particle of this.particles) { if (particle.isManual) { pruned.push(particle); continue; } if (autoCount >= particleCount) continue; pruned.push(particle); autoCount++; } this.particles = pruned; } else { this.particles = this.particles.slice(0, particleCount); } // Only necessary after resize if (updateBounds) { for (const particle of this.particles) { this.#updateParticleBounds(particle); } } for (let i = this.particles.length; i < particleCount; i++) this.#createParticle(); } /** @private Create a random new particle */ #createParticle() { const posX = prng() * this.width; const posY = prng() * this.height; this.createParticle(posX, posY, prng() * TWO_PI, (0.5 + prng() * 0.5) * this.option.particles.relSpeed, (0.5 + Math.pow(prng(), 5) * 2) * this.option.particles.relSize, false); } /** @public Create a new particle with optional parameters */ createParticle(posX, posY, dir, speed, size, isManual = true) { const particle = { posX, // Logical position in pixels posY, // Logical position in pixels x: posX, // Visual position in pixels y: posY, // Visual position in pixels velX: 0, // Horizonal speed in pixels per update velY: 0, // Vertical speed in pixels per update offX: 0, // Horizontal distance from drawn to logical position in pixels offY: 0, // Vertical distance from drawn to logical position in pixels dir: dir, // Direction in radians speed: speed, // Velocity in pixels per update size: size, // Ray in pixels of the particle gridPos: { x: 1, y: 1 }, isVisible: false, isManual, }; this.#updateParticleBounds(particle); this.particles.push(particle); this.hasManualParticles = true; } /** @private Update the visible bounds of a particle */ #updateParticleBounds(particle) { // The particle is considered visible within these bounds particle.bounds = { top: -particle.size, right: this.canvas.width + particle.size, bottom: this.canvas.height + particle.size, left: -particle.size, }; } /* @public Randomize speed and size of all particles based on current options */ updateParticles() { const relSpeed = this.option.particles.relSpeed; const relSize = this.option.particles.relSize; for (const particle of this.particles) { particle.speed = (0.5 + prng() * 0.5) * relSpeed; particle.size = (0.5 + Math.pow(prng(), 5) * 2) * relSize; this.#updateParticleBounds(particle); // because size changed } } /** @private Apply gravity forces between particles */ #updateGravity(step) { const isRepulsiveEnabled = this.option.gravity.repulsive > 0; const isPullingEnabled = this.option.gravity.pulling > 0; if (!isRepulsiveEnabled && !isPullingEnabled) return; const particles = this.particles; const len = particles.length; const connectDist = this.option.particles.connectDist; const gravRepulsiveMult = connectDist * this.option.gravity.repulsive * step; const gravPullingMult = connectDist * this.option.gravity.pulling * step; const maxRepulsiveDist = connectDist / 2; const maxRepulsiveDistSq = maxRepulsiveDist ** 2; const eps = connectDist ** 2 / 256; for (let i = 0; i < len; i++) { const particleA = particles[i]; for (let j = i + 1; j < len; j++) { // Code in this scope runs O(n^2) times per frame! const particleB = particles[j]; const distX = particleA.posX - particleB.posX; const distY = particleA.posY - particleB.posY; const distSq = distX * distX + distY * distY; if (distSq >= maxRepulsiveDistSq && !isPullingEnabled) continue; let angle; let grav; let gravMult; angle = Math.atan2(-distY, -distX); grav = 1 / (distSq + eps); const angleX = Math.cos(angle); const angleY = Math.sin(angle); if (distSq < maxRepulsiveDistSq) { gravMult = grav * gravRepulsiveMult; const gravX = angleX * gravMult; const gravY = angleY * gravMult; particleA.velX -= gravX; particleA.velY -= gravY; particleB.velX += gravX; particleB.velY += gravY; } if (!isPullingEnabled) continue; gravMult = grav * gravPullingMult; const gravX = angleX * gravMult; const gravY = angleY * gravMult; particleA.velX += gravX; particleA.velY += gravY; particleB.velX -= gravX; particleB.velY -= gravY; } } } /** @private Update positions, directions, and visibility of all particles */ #updateParticles(step) { const particles = this.particles; const len = particles.length; const width = this.width; const height = this.height; const offX = this.offX; const offY = this.offY; const mouseX = this.mouseX; const mouseY = this.mouseY; const rotationSpeed = this.option.particles.rotationSpeed * step; const friction = this.option.gravity.friction; const mouseConnectDist = this.option.mouse.connectDist; const mouseDistRatio = this.option.mouse.distRatio; const isMouseInteractionTypeNone = this.option.mouse.interactionType === CanvasParticles.interactionType.NONE; const isMouseInteractionTypeMove = this.option.mouse.interactionType === CanvasParticles.interactionType.MOVE; const easing = 1 - Math.pow(1 - 1 / 4, step); for (let i = 0; i < len; i++) { const particle = particles[i]; particle.dir += 2 * (Math.random() - 0.5) * rotationSpeed * step; particle.dir %= TWO_PI; // Constant velocity const movX = Math.sin(particle.dir) * particle.speed; const movY = Math.cos(particle.dir) * particle.speed; // Apply velocities particle.posX += (movX + particle.velX) * step; particle.posY += (movY + particle.velY) * step; // Wrap particles around the canvas particle.posX %= width; if (particle.posX < 0) particle.posX += width; particle.posY %= height; if (particle.posY < 0) particle.posY += height; // Slightly decrease dynamic velocity particle.velX *= Math.pow(friction, step); particle.velY *= Math.pow(friction, step); // Distance from mouse const distX = particle.posX + offX - mouseX; const distY = particle.posY + offY - mouseY; // Mouse interaction if (!isMouseInteractionTypeNone) { const distRatio = mouseConnectDist / Math.hypot(distX, distY); if (mouseDistRatio < distRatio) { particle.offX += (distRatio * distX - distX - particle.offX) * easing; particle.offY += (distRatio * distY - distY - particle.offY) * easing; } else { particle.offX -= particle.offX * easing; particle.offY -= particle.offY * easing; } } // Visually displace the particles particle.x = particle.posX + particle.offX; particle.y = particle.posY + particle.offY; // Move the particles if (isMouseInteractionTypeMove) { particle.posX = particle.x; particle.posY = particle.y; } particle.x += offX; particle.y += offY; this.#gridPos(particle); particle.isVisible = particle.gridPos.x === 1 && particle.gridPos.y === 1; } } /** * @private Determine a particle's location in a 3x3 canvas grid to assess visibility. * * This helps identify whether two particles, even if off-canvas, might have a visible connection. * * Grid regions: * - { x: 0, y: 0 } = top-left * - { x: 1, y: 0 } = top * - { x: 2, y: 0 } = top-right * - { x: 0, y: 1 } = left * - { x: 1, y: 1 } = center (visible part of the canvas) * - { x: 2, y: 1 } = right * - { x: 0, y: 2 } = bottom-left * - { x: 1, y: 2 } = bottom * - { x: 2, y: 2 } = bottom-right */ #gridPos(particle) { particle.gridPos.x = (+(particle.x >= particle.bounds.left) + +(particle.x > particle.bounds.right)); particle.gridPos.y = (+(particle.y >= particle.bounds.top) + +(particle.y > particle.bounds.bottom)); } /** @private Determines whether a line between 2 particles crosses through the visible center of the canvas */ #isLineVisible(particleA, particleB) { // Visible if either particle is in the center if (particleA.isVisible || particleB.isVisible) return true; // Not visible if both particles are in the same vertical or horizontal line that does not cross the center return !((particleA.gridPos.x === particleB.gridPos.x && particleA.gridPos.x !== 1) || (particleA.gridPos.y === particleB.gridPos.y && particleA.gridPos.y !== 1)); } /** @private Draw the particles on the canvas */ #renderParticles() { const particles = this.particles; const len = particles.length; const ctx = this.ctx; for (let i = 0; i < len; i++) { const particle = particles[i]; if (!particle.isVisible) continue; // Draw particles smaller than 1px as a square instead of a circle for performance if (particle.size > 1) { // Draw circle ctx.beginPath(); ctx.arc(particle.x, particle.y, particle.size, 0, TWO_PI); ctx.fill(); ctx.closePath(); } else { // Draw square (±183% faster) ctx.fillRect(particle.x - particle.size, particle.y - particle.size, particle.size * 2, particle.size * 2); } } } /** @private Draw lines between particles if they are close enough */ #renderConnections() { const particles = this.particles; const len = particles.length; const ctx = this.ctx; const maxDist = this.option.particles.connectDist; const maxDistSq = maxDist ** 2; const halfMaxDist = maxDist / 2; const halfMaxDistSq = halfMaxDist ** 2; const drawAll = maxDist >= Math.min(this.canvas.width, this.canvas.height); const maxWorkPerParticle = maxDistSq * this.option.particles.maxWork; const alpha = this.color.alpha; const alphaFactor = this.color.alpha * maxDist; /** Batch line segments of max alpha */ const bucket = []; for (let i = 0; i < len; i++) { const particleA = particles[i]; let particleWork = 0; for (let j = i + 1; j < len; j++) { // Code in this scope runs O(n^2) times per frame! const particleB = particles[j]; // Don't draw the line if it wouldn't be visible if (!drawAll && !this.#isLineVisible(particleA, particleB)) continue; const distX = particleA.x - particleB.x; const distY = particleA.y - particleB.y; const distSq = distX * distX + distY * distY; // Don't draw the line if the particles are too far away if (distSq > maxDistSq) continue; if (distSq > halfMaxDistSq) { // Calculate line alpha ctx.globalAlpha = alphaFactor / Math.sqrt(distSq) - alpha; // Draw the line ctx.beginPath(); ctx.moveTo(particleA.x, particleA.y); ctx.lineTo(particleB.x, particleB.y); ctx.stroke(); } else { bucket.push([particleA.x, particleA.y, particleB.x, particleB.y]); } // Stop drawing lines from this particle if it has exceeded what's allowed by configuration if ((particleWork += distSq) >= maxWorkPerParticle) break; } } if (!bucket.length) return; // Render all bucketed lines at once ctx.globalAlpha = alpha; ctx.beginPath(); for (let i = 0; i < bucket.length; i++) { const line = bucket[i]; ctx.moveTo(line[0], line[1]); ctx.lineTo(line[2], line[3]); } ctx.stroke(); } /** @private Clear the canvas and render the particles and their connections onto the canvas */ #render() { this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.globalAlpha = this.color.alpha; this.ctx.fillStyle = this.color.hex; this.ctx.strokeStyle = this.color.hex; this.ctx.lineWidth = 1; this.#renderParticles(); if (this.option.particles.drawLines) this.#renderConnections(); } /** @private Main animation loop that updates and renders the particles */ #animation() { if (!this.isAnimating) return; requestAnimationFrame(() => this.#animation()); const now = performance.now(); // Elapsed time since last frame, clamped to avoid large simulation jumps const dt = Math.min(now - this.lastAnimationFrame, CanvasParticles.MAX_DT); // Normalized simulation step: // - step = 1 → exactly one baseline update (dt === BASE_DT) // - step > 1 → more time passed (lower FPS), advance further // - step < 1 → less time passed (higher FPS), advance less const step = dt / CanvasParticles.BASE_DT; this.#updateGravity(step); this.#updateParticles(step); this.#render(); this.lastAnimationFrame = now; } /** @public Start the particle animation if it was not running before */ start({ auto = false } = {}) { if (!this.isAnimating && (!auto || this.enableAnimating)) { this.enableAnimating = true; this.isAnimating = true; this.updateCanvasRect(); requestAnimationFrame(() => this.#animation()); } // Stop animating because it will start automatically once the canvas enters the viewbox if (!this.canvas.inViewbox && this.option.animation.startOnEnter) this.isAnimating = false; return this; } /** @public Stops the particle animation and optionally clears the canvas */ stop({ auto = false, clear = true } = {}) { if (!auto) this.enableAnimating = false; this.isAnimating = false; if (clear !== false) this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); return true; } /** @public Gracefully destroy the instance and remove the canvas element */ destroy() { this.stop(); CanvasParticles.canvasIntersectionObserver.unobserve(this.canvas); CanvasParticles.canvasResizeObserver.unobserve(this.canvas); window.removeEventListener('mousemove', this.handleMouseMove); window.removeEventListener('scroll', this.handleScroll); this.canvas?.remove(); Object.keys(this).forEach((key) => delete this[key]); // Remove references to help GC } /** Set and validate options (https://github.com/Khoeckman/canvasParticles?tab=readme-ov-file#options) */ set options(options) { const pno = CanvasParticles.parseNumericOption; // Format and parse all options this.option = { background: options.background ?? false, animation: { startOnEnter: !!(options.animation?.startOnEnter ?? true), stopOnLeave: !!(options.animation?.stopOnLeave ?? true), }, mouse: { interactionType: ~~pno('mouse.interactionType', options.mouse?.interactionType, CanvasParticles.interactionType.MOVE, { min: 0, max: 2 }), connectDistMult: pno('mouse.connectDistMult', options.mouse?.connectDistMult, 2 / 3, { min: 0 }), connectDist: 1 /* post processed */, distRatio: pno('mouse.distRatio', options.mouse?.distRatio, 2 / 3, { min: 0 }), }, particles: { generationType: ~~pno('particles.generationType', options.particles?.generationType, CanvasParticles.generationType.MATCH, { min: 0, max: 2 }), drawLines: !!(options.particles?.drawLines ?? true), color: options.particles?.color ?? 'black', ppm: ~~pno('particles.ppm', options.particles?.ppm, 100), max: Math.round(pno('particles.max', options.particles?.max, Infinity, { min: 0 })), maxWork: Math.round(pno('particles.maxWork', options.particles?.maxWork, Infinity, { min: 0 })), connectDist: ~~pno('particles.connectDistance', options.particles?.connectDistance, 150, { min: 1 }), relSpeed: pno('particles.relSpeed', options.particles?.relSpeed, 1, { min: 0 }), relSize: pno('particles.relSize', options.particles?.relSize, 1, { min: 0 }), rotationSpeed: pno('particles.rotationSpeed', options.particles?.rotationSpeed, 2, { min: 0 }) / 100, }, gravity: { repulsive: pno('gravity.repulsive', options.gravity?.repulsive, 0, { min: 0 }), pulling: pno('gravity.pulling', options.gravity?.pulling, 0, { min: 0 }), friction: pno('gravity.friction', options.gravity?.friction, 0.8, { min: 0, max: 1 }), }, }; this.setBackground(this.option.background); this.setMouseConnectDistMult(this.option.mouse.connectDistMult); this.setParticleColor(this.option.particles.color); } get options() { return this.option; } /** @public Sets the canvas background */ setBackground(background) { if (!background) return; if (typeof background !== 'string') throw new TypeError('background is not a string'); this.canvas.style.background = this.option.background = background; } /** @public Transform the distance multiplier (float) to absolute distance (px) */ setMouseConnectDistMult(connectDistMult) { const mult = CanvasParticles.parseNumericOption('mouse.connectDistMult', connectDistMult, 2 / 3, { min: 0 }); this.option.mouse.connectDist = this.option.particles.connectDist * mult; } /** @public Format particle color and opacity */ setParticleColor(color) { this.ctx.fillStyle = color; // Check if `ctx.fillStyle` is in hex format ("#RRGGBB") if (String(this.ctx.fillStyle)[0] === '#') { this.color = { hex: String(this.ctx.fillStyle), alpha: 1.0, }; } else { // JavaScript's `ctx.fillStyle` causes the color to otherwise end up in in rgba format ("rgba(136, 244, 255, 0.25)") // Extract the alpha value from the rgba string let alpha = String(this.ctx.fillStyle).split(',').at(-1); // ' 0.25)' alpha = alpha?.slice(1, -1) ?? '1'; // '0.25' // Extracts e.g. 136, 244 and 255 from rgba(136, 244, 255, 0.25) and converts it to '#rrggbb' this.ctx.fillStyle = String(this.ctx.fillStyle).split(',').slice(0, -1).join(',') + ', 1)'; this.color = { hex: String(this.ctx.fillStyle), alpha: isNaN(+alpha) ? 1 : +alpha, }; // 0.25 or 1 } } } module.exports = CanvasParticles;