UNPKG

canvasparticles-js

Version:

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

618 lines (529 loc) 24.7 kB
// Copyright (c) 2022–2025 Kyle Hoeckman, MIT License // https://github.com/Khoeckman/canvasparticles-js/blob/main/LICENSE export default class CanvasParticles { static version = '3.7.4' // Mouse interaction with the particles. static interactionType = Object.freeze({ NONE: 0, // No interaction SHIFT: 1, // Visually shift the particles MOVE: 2, // Actually move the particles }) // Start or stop the animation when the canvas enters or exits the viewport. static canvasIntersectionObserver = new IntersectionObserver(entry => { entry.forEach(change => { const canvas = change.target const instance = canvas.instance // The 'CanvasParticles' instance bound to 'canvas'. if (!instance.options?.animation) return if ((canvas.inViewbox = change.isIntersecting)) instance.options.animation?.startOnEnter && instance.start({ auto: true }) else instance.options.animation?.stopOnLeave && instance.stop({ auto: true, clear: false }) }) }) /** * Creates a new CanvasParticles instance. * @param {string} [selector] - The CSS selector to the canvas element or the HTMLCanvasElement itself. * @param {Object} [options={}] - Object structure: https://github.com/Khoeckman/canvasParticles?tab=readme-ov-file#options */ constructor(selector, options = {}) { // Find the HTMLCanvasElement and assign it to 'this.canvas'. if (selector instanceof HTMLCanvasElement) this.canvas = selector else { if (typeof selector !== 'string') throw new TypeError('selector is not a string and neither a HTMLCanvasElement itself') this.canvas = document.querySelector(selector) if (!(this.canvas instanceof HTMLCanvasElement)) throw new Error('selector does not point to a canvas') } this.canvas.instance = this // Circular assignment to find the instance bound to this canvas. this.canvas.inViewbox = true // Get 2d drawing methods. this.ctx = this.canvas.getContext('2d') this.enableAnimating = false this.animating = false this.particles = [] this.setOptions(options) CanvasParticles.canvasIntersectionObserver.observe(this.canvas) // Setup event handlers this.resizeCanvas = this.resizeCanvas.bind(this) this.updateMousePos = this.updateMousePos.bind(this) window.addEventListener('resize', this.resizeCanvas) this.resizeCanvas() window.addEventListener('mousemove', this.updateMousePos) window.addEventListener('scroll', this.updateMousePos) } resizeCanvas() { this.canvas.width = this.canvas.offsetWidth this.canvas.height = this.canvas.offsetHeight // Prevent the mouse acting like it's at (x: 0, y: 0) before the user moved it. this.mouseX = Infinity this.mouseY = Infinity this.updateCount = Infinity this.width = this.canvas.width + this.options.particles.connectDist * 2 this.height = this.canvas.height + this.options.particles.connectDist * 2 this.offX = (this.canvas.width - this.width) / 2 this.offY = (this.canvas.height - this.height) / 2 if (this.options.particles.regenerateOnResize || this.particles.length === 0) this.newParticles() else this.matchParticleCount({ updateBounds: true }) } updateMousePos(event) { if (!this.enableAnimating) return if (event instanceof MouseEvent) { this.clientX = event.clientX this.clientY = event.clientY } // On scroll, the mouse position remains the same, but since the canvas position changes, `left` and `top` must be recalculated. const { left, top } = this.canvas.getBoundingClientRect() this.mouseX = this.clientX - left this.mouseY = this.clientY - top } /** * Update the target number of particles based on the current canvas size and 'options.particles.ppm'. * Capped at 'options.particles.max'. * * @private * @throws {RangeError} If the particle count is not finite. */ #updateParticleCount() { // Amount of particles to be created const particleCount = ((this.options.particles.ppm * this.width * this.height) / 1_000_000) | 0 this.particleCount = Math.min(this.options.particles.max, particleCount) if (!isFinite(this.particleCount)) throw new RangeError('number of particles must be finite. (options.particles.ppm)') } /** * Remove all particles and generate new ones. * The amount of new particles will match 'options.particles.ppm'. * */ newParticles() { this.#updateParticleCount() this.particles = [] for (let i = 0; i < this.particleCount; i++) this.createParticle() } /** * When resizing, add or remove some particles so that the final amount of particles will match 'options.particles.ppm'. * */ matchParticleCount({ updateBounds } = {}) { this.#updateParticleCount() this.particles = this.particles.slice(0, this.particleCount) if (updateBounds) this.particles.forEach(particle => this.#updateParticleBounds(particle)) while (this.particleCount > this.particles.length) this.createParticle() } createParticle(posX, posY, dir, speed, size) { const particle = { posX: posX - this.offX || Math.random() * this.width, // Logical position in pixels posY: posY - this.offY || Math.random() * this.height, // 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 || Math.random() * 2 * Math.PI, // Direction in radians speed: speed || (0.5 + Math.random() * 0.5) * this.options.particles.relSpeed, // Velocity in pixels per update size: size || (0.5 + Math.random() ** 5 * 2) * this.options.particles.relSize, // Ray in pixels of the particle } this.#updateParticleBounds(particle) this.particles.push(particle) } #updateParticleBounds(particle) { // Within these bounds the particle is considered visible. particle.bounds = { top: -particle.size, right: this.canvas.width + particle.size, bottom: this.canvas.height + particle.size, left: -particle.size, } } /** * Calculates the gravity properties of each particle on the next frame. * Is executed once every 'options.framesPerUpdate' frames. * * @private * */ #updateGravity() { const isRepulsiveEnabled = this.options.gravity.repulsive !== 0 const isPullingEnabled = this.options.gravity.pulling !== 0 if (isRepulsiveEnabled || isPullingEnabled) { const len = this.particleCount const gravRepulsiveMult = this.options.particles.connectDist * this.options.gravity.repulsive const gravPullingMult = this.options.particles.connectDist * this.options.gravity.pulling const maxRepulsiveDist = this.options.particles.connectDist / 2 const maxGrav = this.options.particles.connectDist * 0.1 for (let i = 0; i < len; i++) { for (let j = i + 1; j < len; j++) { // Code in this scope runs { particleCount ** 2 / 2 } times per update! const particleA = this.particles[i] const particleB = this.particles[j] const distX = particleA.posX - particleB.posX const distY = particleA.posY - particleB.posY const dist = Math.sqrt(distX * distX + distY * distY) let angle, grav if (dist < maxRepulsiveDist) { // Apply repulsive force on all particles closer than `dist` / 2. angle = Math.atan2(particleB.posY - particleA.posY, particleB.posX - particleA.posX) grav = (1 / dist) ** 1.8 const gravMult = Math.min(maxGrav, grav * gravRepulsiveMult) const gravX = Math.cos(angle) * gravMult const gravY = Math.sin(angle) * gravMult particleA.velX -= gravX particleA.velY -= gravY particleB.velX += gravX particleB.velY += gravY } if (!isPullingEnabled) continue // Apply pulling force on all particles. if (angle === undefined) { angle = Math.atan2(particleB.posY - particleA.posY, particleB.posX - particleA.posX) grav = (1 / dist) ** 1.8 } const gravMult = Math.min(maxGrav, grav * gravPullingMult) const gravX = Math.cos(angle) * gravMult const gravY = Math.sin(angle) * gravMult particleA.velX += gravX particleA.velY += gravY particleB.velX -= gravX particleB.velY -= gravY } } } } /** * Calculates the properties of each particle on the next frame. * Is executed once every 'options.framesPerUpdate' frames. * * @private * */ #updateParticles() { for (let particle of this.particles) { // Slightly, randomly change the particle's direction and move it in that direction. particle.dir = (particle.dir + Math.random() * this.options.particles.rotationSpeed * 2 - this.options.particles.rotationSpeed) % (2 * Math.PI) particle.velX *= this.options.gravity.friction particle.velY *= this.options.gravity.friction particle.posX = (particle.posX + particle.velX + ((Math.sin(particle.dir) * particle.speed) % this.width) + this.width) % this.width particle.posY = (particle.posY + particle.velY + ((Math.cos(particle.dir) * particle.speed) % this.height) + this.height) % this.height const distX = particle.posX + this.offX - this.mouseX const distY = particle.posY + this.offY - this.mouseY // If the `interactionType` is not `NONE`, calculate how much to move the particle away from the mouse. if (this.options.mouse.interactionType !== CanvasParticles.interactionType.NONE) { const distRatio = this.options.mouse.connectDist / Math.hypot(distX, distY) if (this.options.mouse.distRatio < distRatio) { particle.offX += (distRatio * distX - distX - particle.offX) / 4 particle.offY += (distRatio * distY - distY - particle.offY) / 4 } else { particle.offX -= particle.offX / 4 particle.offY -= particle.offY / 4 } } // Visually shift the particles particle.x = particle.posX + particle.offX particle.y = particle.posY + particle.offY // Actually move the particles if the `interactionType` is `MOVE`. if (this.options.mouse.interactionType === CanvasParticles.interactionType.MOVE) { particle.posX = particle.x particle.posY = particle.y } particle.x += this.offX particle.y += this.offY particle.gridPos = this.#gridPos(particle) particle.isVisible = particle.gridPos.x === 1 && particle.gridPos.y === 1 } } /** * Determines the location of the particle in a 3x3 grid on the canvas. * The grid represents different regions of the canvas: * * - { 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 * * @private * @param {Object} particle - The coordinates of the particle. * @param {number} particle.x - The x-coordinate of the particle. * @param {number} particle.y - The y-coordinate of the particle. * @returns {Object} - The calculated grid position of the particle. * @returns {number} x - The horizontal grid position (0, 1, or 2). * @returns {number} y - The vertical grid position (0, 1, or 2). */ #gridPos(particle) { return { x: (particle.x >= particle.bounds.left) + (particle.x > particle.bounds.right), y: (particle.y >= particle.bounds.top) + (particle.y > particle.bounds.bottom), } } /** * Determines whether a line between 2 particles crosses through the visible center of the canvas. * * @private * @param {Object} particleA - First particle with {gridPos, isVisible}. * @param {Object} particleB - Second particle with {gridPos, isVisible}. * @returns {boolean} - True if the line crosses the visible center, false otherwise. */ #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 but outside the center. return !( (particleA.gridPos.x === particleB.gridPos.x && particleA.gridPos.x !== 1) || (particleA.gridPos.y === particleB.gridPos.y && particleA.gridPos.y !== 1) ) } /** * Precomputes and caches stroke style strings for a given base color and all possible alpha values (0–255). * This is necessary because the rendering process involves up to { particleCount ** 2 / 2 } lookups per frame. * * @private * @param {string} color - The base color in the format '#rrggbb'. * @returns {Object} - A lookup table mapping each alpha value (0–255) to its corresponding stroke style string in the format `#rrggbbaa`. * * @example * const strokeStyleTable = this.#generateStrokeStyleTable("#abcdef"); * strokeStyleTable[128] -> "#abcdef80" * strokeStyleTable[255] -> "#abcdefff" * * Notes: * - This function precomputes all possible stroke styles by appending a two-character hexadecimal alpha value (0x00–0xFF) to the base color. * - The table is stored in `this.strokeStyleTable` for quick lookups. */ #generateStrokeStyleTable(color) { const table = {} // Precompute stroke styles for alpha values 0–255 for (let alpha = 0; alpha < 256; alpha++) { // Convert to 2-character hex and combine base color with alpha table[alpha] = color + alpha.toString(16).padStart(2, '0') } return table } /** * Renders the particles on the canvas. * * @private */ #renderParticles() { for (let particle of this.particles) { if (particle.isVisible) { // Draw the particle as a square if the size is smaller than 1 pixel. // This is ±183% faster than drawing all particle's as circles. if (particle.size > 1) { // Draw circle this.ctx.beginPath() this.ctx.arc(particle.x, particle.y, particle.size, 0, 2 * Math.PI) this.ctx.fill() this.ctx.closePath() } else { // Draw square this.ctx.fillRect(particle.x - particle.size, particle.y - particle.size, particle.size * 2, particle.size * 2) } } } } /** * Connects particles with lines if they are within the connection distance. * * @private */ #renderConnections() { const len = this.particleCount const drawAll = this.options.particles.connectDist >= Math.min(this.canvas.width, this.canvas.height) const maxWorkPerParticle = this.options.particles.connectDist * this.options.particles.maxWork for (let i = 0; i < len; i++) { let particleWork = 0 for (let j = i + 1; j < len; j++) { // Code in this scope runs { particleCount ** 2 / 2 } times per frame! const particleA = this.particles[i] const particleB = this.particles[j] if (!(drawAll || this.#isLineVisible(particleA, particleB))) continue // Draw a line only if it's visible. const distX = particleA.x - particleB.x const distY = particleA.y - particleB.y const dist = Math.sqrt(distX * distX + distY * distY) // Don't connect the 2 particles with a line if their distance is greater than `options.particles.connectDist`. if (dist > this.options.particles.connectDist) continue // Calculate the transparency of the line and lookup the stroke style. // This is the heaviest task of the entire animation process. if (dist > this.options.particles.connectDist / 2) { const alpha = (Math.min(this.options.particles.connectDist / dist - 1, 1) * this.options.particles.opacity) | 0 this.ctx.strokeStyle = this.strokeStyleTable[alpha] } else { this.ctx.strokeStyle = this.options.particles.colorWithAlpha } // Draw the line. this.ctx.beginPath() this.ctx.moveTo(particleA.x, particleA.y) this.ctx.lineTo(particleB.x, particleB.y) this.ctx.stroke() // Stop drawing lines from this particles if it has already drawn to many. if ((particleWork += dist) >= maxWorkPerParticle) break } } } /** * Clear the canvas and render the particles and their connections onto the canvas. * * @private */ #render() { this.canvas.width = this.canvas.width this.ctx.fillStyle = this.options.particles.colorWithAlpha this.ctx.lineWidth = 1 this.#renderParticles() this.#renderConnections() } /** * Main animation loop that updates and renders the particles. * Runs recursively using 'requestAnimationFrame'. * * @private */ #animation() { if (!this.animating) return requestAnimationFrame(() => this.#animation()) if (++this.updateCount >= this.options.framesPerUpdate) { this.updateCount = 0 this.#updateGravity() this.#updateParticles() this.#render() } } /** * Public functions */ /** * Starts the particle animation. * * - If the animation is already running, do nothing. * - If the canvas is not within the viewbox and 'startOnEnter' is enabled, animation will be stopped until it enters the viewbox. * * @param {Object} [options] - Optional configuration for starting the animation. * @param {boolean} [options.auto] - If true, indicates that the request comes from within. * @returns {CanvasParticles} The current instance for method chaining. */ start(options) { if (!this.animating && (!options?.auto || this.enableAnimating)) { this.enableAnimating = true this.animating = true requestAnimationFrame(() => this.#animation()) } // Stop animating because it will start automatically once the canvas enters the viewbox. if (!this.canvas.inViewbox && this.options.animation.startOnEnter) this.animating = false return this } /** * Stops the particle animation and optionally clears the canvas. * * - If `clear` is not strictly `false`, the canvas will be cleared. * * @param {Object} [options={}] - Optional configuration object. * @param {boolean} [options.auto=false] - If `true`, indicates the stop request came from inside * the animation loop rather than an external call. * @param {boolean} [options.clear=true] - If `false`, prevents the canvas from being cleared * when the animation stops. * @returns {boolean} `true` if the animation state was successfully updated. */ stop({ auto, clear } = {}) { if (!auto) this.enableAnimating = false this.animating = false if (clear !== false) this.canvas.width = this.canvas.width return true } /** * Gracefully destroy the instance and remove the canvas element. */ destroy() { this.stop() CanvasParticles.canvasIntersectionObserver.unobserve(this.canvas) window.removeEventListener('resize', this.resizeCanvas) window.removeEventListener('mousemove', this.updateMousePos) window.removeEventListener('scroll', this.updateMousePos) this.canvas?.remove() Object.keys(this).forEach(key => delete this[key]) // Remove references to help GC. } /** * Public setters */ /** * Set and validate the options object. * @param {Object} options - Object structure: https://github.com/Khoeckman/canvasParticles?tab=readme-ov-file#options */ setOptions(options) { // Returns `defaultValue` if `value` is NaN, else returns `value`. const parse = (value, defaultValue) => (isNaN(+value) ? defaultValue : +value) // Format or default all options. this.options = { background: options.background ?? false, framesPerUpdate: parse(Math.max(1, parseInt(options.framesPerUpdate)), 1), animation: { startOnEnter: !!(options.animation?.startOnEnter ?? true), stopOnLeave: !!(options.animation?.stopOnLeave ?? true), }, mouse: { interactionType: parse(parseInt(options.mouse?.interactionType), 1), connectDistMult: parse(options.mouse?.connectDistMult, 2 / 3), distRatio: parse(options.mouse?.distRatio, 2 / 3), }, particles: { regenerateOnResize: !!options.particles?.regenerateOnResize, color: options.particles?.color ?? 'black', ppm: parse(options.particles?.ppm, 100), max: parse(options.particles?.max, 500), maxWork: parse(Math.max(0, options.particles?.maxWork), Infinity), connectDist: parse(Math.max(1, options.particles?.connectDistance), 150), relSpeed: parse(Math.max(0, options.particles?.relSpeed), 1), relSize: parse(Math.max(0, options.particles?.relSize), 1), rotationSpeed: parse(Math.max(0, options.particles?.rotationSpeed / 100), 0.02), }, gravity: { repulsive: parse(options.gravity?.repulsive, 0), pulling: parse(options.gravity?.pulling, 0), friction: parse(Math.max(0, Math.min(1, options.particles?.friction)), 0.8), }, } this.setBackground(this.options.background) this.setMouseConnectDistMult(this.options.mouse.connectDistMult) this.setParticleColor(this.options.particles.color) } /** * Sets the canvas background. * * @param {string} background - The style of the background. Can be any CSS-supported background value. * @throws {TypeError} If background is not a string. */ setBackground(background) { if (background === false) return if (typeof background !== 'string') throw new TypeError('background is not a string') this.canvas.style.background = this.options.background = background } /** * Transform distance multiplier to absolute distance. * @param {float} connectDistMult - The maximum distance for the mouse to interact with the particles. * The value is multiplied by 'particles.connectDistance'. * @example 0.8 connectDistMult * 150 particles.connectDistance = 120 pixels */ setMouseConnectDistMult(connectDistMult) { this.options.mouse.connectDist = this.options.particles.connectDist * (isNaN(connectDistMult) ? 2 / 3 : connectDistMult) } /** * Format particle color and opacity. * @param {string} color - The color of the particles and their connections. Can be any CSS supported color format. */ setParticleColor(color) { this.ctx.fillStyle = color // Check if 'ctx.fillStyle' is in hex format ("#RRGGBB" without alpha). if (this.ctx.fillStyle[0] === '#') this.options.particles.opacity = 255 else { // JavaScript's 'ctx.fillStyle' ensures the color will otherwise be in rgba format (e.g., "rgba(136, 244, 255, 0.25)") // Extract the alpha value (0.25) from the rgba string, scale it to the range 0x00 to 0xff, // and convert it to an integer. This value represents the opacity as a 2-character hex string. this.options.particles.opacity = (this.ctx.fillStyle.split(',').at(-1).slice(1, -1) * 255) | 0 // Example: extract 136, 244 and 255 from rgba(136, 244, 255, 0.25) and convert to hexadecimal '#rrggbb' format. this.ctx.fillStyle = this.ctx.fillStyle.split(',').slice(0, -1).join(',') + ', 1)' } this.options.particles.color = this.ctx.fillStyle this.options.particles.colorWithAlpha = this.options.particles.color + this.options.particles.opacity.toString(16) // Recalculate the stroke style table. this.strokeStyleTable = this.#generateStrokeStyleTable(this.options.particles.color) } }