canvasparticles-js 4.3.0 → 4.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -21,7 +21,7 @@ function Mulberry32(seed) {
21
21
  // Spectral test: /demo/mulberry32.html
22
22
  const prng = Mulberry32(Math.random() * 2 ** 32).next;
23
23
  class CanvasParticles {
24
- static version = "4.3.0";
24
+ static version = "4.3.1";
25
25
  static MAX_DT = 1000 / 50; // milliseconds between updates @ 50 FPS
26
26
  static BASE_DT = 1000 / 60; // milliseconds between updates @ 60 FPS
27
27
  /** Defines mouse interaction types with the particles */
@@ -73,10 +73,10 @@ class CanvasParticles {
73
73
  if (value == undefined)
74
74
  return defaultValue;
75
75
  const { min = -Infinity, max = Infinity } = clamp ?? {};
76
- if (isFinite(min) && value < min) {
76
+ if (value < min) {
77
77
  console.warn(new RangeError(`option.${name} was clamped to ${min} as ${value} is too low`));
78
78
  }
79
- else if (isFinite(max) && value > max) {
79
+ else if (value > max) {
80
80
  console.warn(new RangeError(`option.${name} was clamped to ${max} as ${value} is too high`));
81
81
  }
82
82
  return CanvasParticles.defaultIfNaN(Math.min(Math.max(value ?? defaultValue, min), max), defaultValue);
@@ -87,6 +87,7 @@ class CanvasParticles {
87
87
  isAnimating = false;
88
88
  lastAnimationFrame = 0;
89
89
  particles = [];
90
+ hasManualParticles = false; // set to true once @public createParticle() is used
90
91
  clientX = Infinity;
91
92
  clientY = Infinity;
92
93
  mouseX = Infinity;
@@ -195,23 +196,70 @@ class CanvasParticles {
195
196
  /** @public Remove existing particles and generate new ones */
196
197
  newParticles() {
197
198
  const particleCount = this.#targetParticleCount();
198
- this.particles = [];
199
+ if (this.hasManualParticles) {
200
+ this.particles = this.particles.filter((particle) => particle.manual);
201
+ this.hasManualParticles = this.particles.length > 0;
202
+ }
203
+ else {
204
+ this.particles = [];
205
+ }
199
206
  for (let i = 0; i < particleCount; i++)
200
- this.createParticle();
207
+ this.#createParticle();
201
208
  }
202
209
  /** @public Adjust particle array length to match `options.particles.ppm` */
203
210
  matchParticleCount({ updateBounds = false } = {}) {
204
211
  const particleCount = this.#targetParticleCount();
205
- this.particles = this.particles.slice(0, particleCount);
206
- if (updateBounds)
207
- this.particles.forEach((particle) => this.#updateParticleBounds(particle));
208
- while (particleCount > this.particles.length)
209
- this.createParticle();
212
+ if (this.hasManualParticles) {
213
+ const pruned = [];
214
+ let autoCount = 0;
215
+ // Keep manual particles while pruning automatic particles that exceed `particleCount`
216
+ // Only count automatic particles towards `particledCount`
217
+ for (const particle of this.particles) {
218
+ if (autoCount >= particleCount)
219
+ break;
220
+ if (particle.manual)
221
+ autoCount++;
222
+ pruned.push(particle);
223
+ }
224
+ this.particles = pruned;
225
+ }
226
+ else {
227
+ this.particles = this.particles.slice(0, particleCount);
228
+ }
229
+ // Only necessary after resize
230
+ if (updateBounds) {
231
+ for (const particle of this.particles) {
232
+ this.#updateParticleBounds(particle);
233
+ }
234
+ }
235
+ for (let i = this.particles.length; i < particleCount; i++)
236
+ this.#createParticle();
237
+ }
238
+ /** @private Create a random new particle */
239
+ #createParticle() {
240
+ const posX = prng() * this.width;
241
+ const posY = prng() * this.height;
242
+ const particle = {
243
+ posX, // Logical position in pixels
244
+ posY, // Logical position in pixels
245
+ x: posX, // Visual position in pixels
246
+ y: posY, // Visual position in pixels
247
+ velX: 0, // Horizonal speed in pixels per update
248
+ velY: 0, // Vertical speed in pixels per update
249
+ offX: 0, // Horizontal distance from drawn to logical position in pixels
250
+ offY: 0, // Vertical distance from drawn to logical position in pixels
251
+ dir: prng() * TWO_PI, // Direction in radians
252
+ speed: (0.5 + prng() * 0.5) * this.option.particles.relSpeed, // Velocity in pixels per update
253
+ size: (0.5 + Math.pow(prng(), 5) * 2) * this.option.particles.relSize, // Ray in pixels of the particle
254
+ gridPos: { x: 1, y: 1 },
255
+ isVisible: false,
256
+ manual: false,
257
+ };
258
+ this.#updateParticleBounds(particle);
259
+ this.particles.push(particle);
210
260
  }
211
261
  /** @public Create a new particle with optional parameters */
212
262
  createParticle(posX, posY, dir, speed, size) {
213
- posX = typeof posX === 'number' ? posX - this.offX : prng() * this.width;
214
- posY = typeof posY === 'number' ? posY - this.offY : prng() * this.height;
215
263
  const particle = {
216
264
  posX, // Logical position in pixels
217
265
  posY, // Logical position in pixels
@@ -221,14 +269,16 @@ class CanvasParticles {
221
269
  velY: 0, // Vertical speed in pixels per update
222
270
  offX: 0, // Horizontal distance from drawn to logical position in pixels
223
271
  offY: 0, // Vertical distance from drawn to logical position in pixels
224
- dir: dir ?? prng() * TWO_PI, // Direction in radians
225
- speed: speed ?? (0.5 + prng() * 0.5) * this.option.particles.relSpeed, // Velocity in pixels per update
226
- size: size ?? (0.5 + Math.pow(prng(), 5) * 2) * this.option.particles.relSize, // Ray in pixels of the particle
272
+ dir: dir, // Direction in radians
273
+ speed: speed, // Velocity in pixels per update
274
+ size: size, // Ray in pixels of the particle
227
275
  gridPos: { x: 1, y: 1 },
228
276
  isVisible: false,
277
+ manual: true,
229
278
  };
230
279
  this.#updateParticleBounds(particle);
231
280
  this.particles.push(particle);
281
+ this.hasManualParticles = true;
232
282
  }
233
283
  /** @private Update the visible bounds of a particle */
234
284
  #updateParticleBounds(particle) {
package/dist/index.d.ts CHANGED
@@ -29,6 +29,7 @@ export default class CanvasParticles {
29
29
  isAnimating: boolean;
30
30
  private lastAnimationFrame;
31
31
  particles: Particle[];
32
+ hasManualParticles: boolean;
32
33
  private clientX;
33
34
  private clientY;
34
35
  mouseX: number;
@@ -59,7 +60,7 @@ export default class CanvasParticles {
59
60
  updateBounds?: boolean;
60
61
  }): void;
61
62
  /** @public Create a new particle with optional parameters */
62
- createParticle(posX?: number, posY?: number, dir?: number, speed?: number, size?: number): void;
63
+ createParticle(posX: number, posY: number, dir: number, speed: number, size: number): void;
63
64
  updateParticles(): void;
64
65
  /** @public Start the particle animation if it was not running before */
65
66
  start({ auto }?: {
package/dist/index.mjs CHANGED
@@ -19,7 +19,7 @@ function Mulberry32(seed) {
19
19
  // Spectral test: /demo/mulberry32.html
20
20
  const prng = Mulberry32(Math.random() * 2 ** 32).next;
21
21
  class CanvasParticles {
22
- static version = "4.3.0";
22
+ static version = "4.3.1";
23
23
  static MAX_DT = 1000 / 50; // milliseconds between updates @ 50 FPS
24
24
  static BASE_DT = 1000 / 60; // milliseconds between updates @ 60 FPS
25
25
  /** Defines mouse interaction types with the particles */
@@ -71,10 +71,10 @@ class CanvasParticles {
71
71
  if (value == undefined)
72
72
  return defaultValue;
73
73
  const { min = -Infinity, max = Infinity } = clamp ?? {};
74
- if (isFinite(min) && value < min) {
74
+ if (value < min) {
75
75
  console.warn(new RangeError(`option.${name} was clamped to ${min} as ${value} is too low`));
76
76
  }
77
- else if (isFinite(max) && value > max) {
77
+ else if (value > max) {
78
78
  console.warn(new RangeError(`option.${name} was clamped to ${max} as ${value} is too high`));
79
79
  }
80
80
  return CanvasParticles.defaultIfNaN(Math.min(Math.max(value ?? defaultValue, min), max), defaultValue);
@@ -85,6 +85,7 @@ class CanvasParticles {
85
85
  isAnimating = false;
86
86
  lastAnimationFrame = 0;
87
87
  particles = [];
88
+ hasManualParticles = false; // set to true once @public createParticle() is used
88
89
  clientX = Infinity;
89
90
  clientY = Infinity;
90
91
  mouseX = Infinity;
@@ -193,23 +194,70 @@ class CanvasParticles {
193
194
  /** @public Remove existing particles and generate new ones */
194
195
  newParticles() {
195
196
  const particleCount = this.#targetParticleCount();
196
- this.particles = [];
197
+ if (this.hasManualParticles) {
198
+ this.particles = this.particles.filter((particle) => particle.manual);
199
+ this.hasManualParticles = this.particles.length > 0;
200
+ }
201
+ else {
202
+ this.particles = [];
203
+ }
197
204
  for (let i = 0; i < particleCount; i++)
198
- this.createParticle();
205
+ this.#createParticle();
199
206
  }
200
207
  /** @public Adjust particle array length to match `options.particles.ppm` */
201
208
  matchParticleCount({ updateBounds = false } = {}) {
202
209
  const particleCount = this.#targetParticleCount();
203
- this.particles = this.particles.slice(0, particleCount);
204
- if (updateBounds)
205
- this.particles.forEach((particle) => this.#updateParticleBounds(particle));
206
- while (particleCount > this.particles.length)
207
- this.createParticle();
210
+ if (this.hasManualParticles) {
211
+ const pruned = [];
212
+ let autoCount = 0;
213
+ // Keep manual particles while pruning automatic particles that exceed `particleCount`
214
+ // Only count automatic particles towards `particledCount`
215
+ for (const particle of this.particles) {
216
+ if (autoCount >= particleCount)
217
+ break;
218
+ if (particle.manual)
219
+ autoCount++;
220
+ pruned.push(particle);
221
+ }
222
+ this.particles = pruned;
223
+ }
224
+ else {
225
+ this.particles = this.particles.slice(0, particleCount);
226
+ }
227
+ // Only necessary after resize
228
+ if (updateBounds) {
229
+ for (const particle of this.particles) {
230
+ this.#updateParticleBounds(particle);
231
+ }
232
+ }
233
+ for (let i = this.particles.length; i < particleCount; i++)
234
+ this.#createParticle();
235
+ }
236
+ /** @private Create a random new particle */
237
+ #createParticle() {
238
+ const posX = prng() * this.width;
239
+ const posY = prng() * this.height;
240
+ const particle = {
241
+ posX, // Logical position in pixels
242
+ posY, // Logical position in pixels
243
+ x: posX, // Visual position in pixels
244
+ y: posY, // Visual position in pixels
245
+ velX: 0, // Horizonal speed in pixels per update
246
+ velY: 0, // Vertical speed in pixels per update
247
+ offX: 0, // Horizontal distance from drawn to logical position in pixels
248
+ offY: 0, // Vertical distance from drawn to logical position in pixels
249
+ dir: prng() * TWO_PI, // Direction in radians
250
+ speed: (0.5 + prng() * 0.5) * this.option.particles.relSpeed, // Velocity in pixels per update
251
+ size: (0.5 + Math.pow(prng(), 5) * 2) * this.option.particles.relSize, // Ray in pixels of the particle
252
+ gridPos: { x: 1, y: 1 },
253
+ isVisible: false,
254
+ manual: false,
255
+ };
256
+ this.#updateParticleBounds(particle);
257
+ this.particles.push(particle);
208
258
  }
209
259
  /** @public Create a new particle with optional parameters */
210
260
  createParticle(posX, posY, dir, speed, size) {
211
- posX = typeof posX === 'number' ? posX - this.offX : prng() * this.width;
212
- posY = typeof posY === 'number' ? posY - this.offY : prng() * this.height;
213
261
  const particle = {
214
262
  posX, // Logical position in pixels
215
263
  posY, // Logical position in pixels
@@ -219,14 +267,16 @@ class CanvasParticles {
219
267
  velY: 0, // Vertical speed in pixels per update
220
268
  offX: 0, // Horizontal distance from drawn to logical position in pixels
221
269
  offY: 0, // Vertical distance from drawn to logical position in pixels
222
- dir: dir ?? prng() * TWO_PI, // Direction in radians
223
- speed: speed ?? (0.5 + prng() * 0.5) * this.option.particles.relSpeed, // Velocity in pixels per update
224
- size: size ?? (0.5 + Math.pow(prng(), 5) * 2) * this.option.particles.relSize, // Ray in pixels of the particle
270
+ dir: dir, // Direction in radians
271
+ speed: speed, // Velocity in pixels per update
272
+ size: size, // Ray in pixels of the particle
225
273
  gridPos: { x: 1, y: 1 },
226
274
  isVisible: false,
275
+ manual: true,
227
276
  };
228
277
  this.#updateParticleBounds(particle);
229
278
  this.particles.push(particle);
279
+ this.hasManualParticles = true;
230
280
  }
231
281
  /** @private Update the visible bounds of a particle */
232
282
  #updateParticleBounds(particle) {
package/dist/index.umd.js CHANGED
@@ -1 +1 @@
1
- !function(t,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(t="undefined"!=typeof globalThis?globalThis:t||self).CanvasParticles=i()}(this,function(){"use strict";const t=2*Math.PI;const i=function(t){let i=t>>>0;return{next(){let t=i+1831565813|0;return i=t,t=Math.imul(t^t>>>15,1|t),t^=t+Math.imul(t^t>>>7,61|t),((t^t>>>14)>>>0)/4294967296}}}(Math.random()*2**32).next;class e{static version="4.3.0";static MAX_DT=20;static BASE_DT=1e3/60;static interactionType=Object.freeze({NONE:0,SHIFT:1,MOVE:2});static generationType=Object.freeze({MANUAL:0,NEW:1,MATCH:2});static canvasIntersectionObserver=new IntersectionObserver(t=>{for(let i=0;i<t.length;i++){const e=t[i],s=e.target,n=s.instance;if(!n.options?.animation)return;(s.inViewbox=e.isIntersecting)?n.options.animation?.startOnEnter&&n.start({auto:!0}):n.options.animation?.stopOnLeave&&n.stop({auto:!0,clear:!1})}},{rootMargin:"-1px"});static canvasResizeObserver=new ResizeObserver(t=>{for(let i=0;i<t.length;i++){t[i].target.instance.updateCanvasRect()}for(let i=0;i<t.length;i++){t[i].target.instance.resizeCanvas()}});static defaultIfNaN=(t,i)=>isNaN(+t)?i:+t;static parseNumericOption=(t,i,s,n)=>{if(null==i)return s;const{min:o=-1/0,max:a=1/0}=n??{};return isFinite(o)&&i<o?console.warn(new RangeError(`option.${t} was clamped to ${o} as ${i} is too low`)):isFinite(a)&&i>a&&console.warn(new RangeError(`option.${t} was clamped to ${a} as ${i} is too high`)),e.defaultIfNaN(Math.min(Math.max(i??s,o),a),s)};canvas;ctx;enableAnimating=!1;isAnimating=!1;lastAnimationFrame=0;particles=[];clientX=1/0;clientY=1/0;mouseX=1/0;mouseY=1/0;width;height;offX;offY;option;color;constructor(t,i={}){let s;if(t instanceof HTMLCanvasElement)s=t;else{if("string"!=typeof t)throw new TypeError("selector is not a string and neither a HTMLCanvasElement itself");if(s=document.querySelector(t),!(s instanceof HTMLCanvasElement))throw new Error("selector does not point to a canvas")}this.canvas=s,this.canvas.instance=this,this.canvas.inViewbox=!0;const n=this.canvas.getContext("2d");if(!n)throw new Error("failed to get 2D context from canvas");this.ctx=n,this.options=i,e.canvasIntersectionObserver.observe(this.canvas),e.canvasResizeObserver.observe(this.canvas),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:!0}),window.addEventListener("scroll",this.handleScroll,{passive:!0})}updateCanvasRect(){const{top:t,left:i,width:e,height:s}=this.canvas.getBoundingClientRect();this.canvas.rect={top:t,left:i,width:e,height:s}}handleMouseMove(t){this.enableAnimating&&(this.clientX=t.clientX,this.clientY=t.clientY,this.isAnimating&&this.updateMousePos())}handleScroll(){this.enableAnimating&&(this.updateCanvasRect(),this.isAnimating&&this.updateMousePos())}updateMousePos(){const{top:t,left:i}=this.canvas.rect;this.mouseX=this.clientX-i,this.mouseY=this.clientY-t}resizeCanvas(){const t=this.canvas.width=this.canvas.rect.width,i=this.canvas.height=this.canvas.rect.height;this.mouseX=1/0,this.mouseY=1/0,this.width=Math.max(t+2*this.option.particles.connectDist,1),this.height=Math.max(i+2*this.option.particles.connectDist,1),this.offX=(t-this.width)/2,this.offY=(i-this.height)/2;const s=this.option.particles.generationType;s!==e.generationType.MANUAL&&(s===e.generationType.NEW||0===this.particles.length?this.newParticles():s===e.generationType.MATCH&&this.matchParticleCount({updateBounds:!0})),this.isAnimating&&this.#t()}#i(){let t=Math.round(this.option.particles.ppm*this.width*this.height/1e6);if(t=Math.min(this.option.particles.max,t),!isFinite(t))throw new RangeError("particleCount must be finite");return 0|t}newParticles(){const t=this.#i();this.particles=[];for(let i=0;i<t;i++)this.createParticle()}matchParticleCount({updateBounds:t=!1}={}){const i=this.#i();for(this.particles=this.particles.slice(0,i),t&&this.particles.forEach(t=>this.#e(t));i>this.particles.length;)this.createParticle()}createParticle(e,s,n,o,a){const r={posX:e="number"==typeof e?e-this.offX:i()*this.width,posY:s="number"==typeof s?s-this.offY:i()*this.height,x:e,y:s,velX:0,velY:0,offX:0,offY:0,dir:n??i()*t,speed:o??(.5+.5*i())*this.option.particles.relSpeed,size:a??(.5+2*Math.pow(i(),5))*this.option.particles.relSize,gridPos:{x:1,y:1},isVisible:!1};this.#e(r),this.particles.push(r)}#e(t){t.bounds={top:-t.size,right:this.canvas.width+t.size,bottom:this.canvas.height+t.size,left:-t.size}}updateParticles(){const t=this.particles,e=t.length,s=this.option.particles.relSpeed,n=this.option.particles.relSize;for(let o=0;o<e;o++){const e=t[o];e.speed=(.5+.5*i())*s,e.size=(.5+2*Math.pow(i(),5))*n}}#s(t){const i=this.option.gravity.repulsive>0,e=this.option.gravity.pulling>0;if(!i&&!e)return;const s=this.particles,n=s.length,o=this.option.particles.connectDist,a=o*this.option.gravity.repulsive*t,r=o*this.option.gravity.pulling*t,c=(o/2)**2,h=o**2/256;for(let t=0;t<n;t++){const i=s[t];for(let o=t+1;o<n;o++){const t=s[o],n=i.posX-t.posX,l=i.posY-t.posY,p=n*n+l*l;if(p>=c&&!e)continue;let u,d,m;u=Math.atan2(-l,-n),d=1/(p+h);const f=Math.cos(u),g=Math.sin(u);if(p<c){m=d*a;const e=f*m,s=g*m;i.velX-=e,i.velY-=s,t.velX+=e,t.velY+=s}if(!e)continue;m=d*r;const v=f*m,y=g*m;i.velX+=v,i.velY+=y,t.velX-=v,t.velY-=y}}}#n(i){const s=this.particles,n=s.length,o=this.width,a=this.height,r=this.offX,c=this.offY,h=this.mouseX,l=this.mouseY,p=this.option.particles.rotationSpeed*i,u=this.option.gravity.friction,d=this.option.mouse.connectDist,m=this.option.mouse.distRatio,f=this.option.mouse.interactionType===e.interactionType.NONE,g=this.option.mouse.interactionType===e.interactionType.MOVE,v=1-Math.pow(.75,i);for(let e=0;e<n;e++){const n=s[e];n.dir+=2*(Math.random()-.5)*p*i,n.dir%=t;const y=Math.sin(n.dir)*n.speed,x=Math.cos(n.dir)*n.speed;n.posX+=(y+n.velX)*i,n.posY+=(x+n.velY)*i,n.posX%=o,n.posX<0&&(n.posX+=o),n.posY%=a,n.posY<0&&(n.posY+=a),n.velX*=Math.pow(u,i),n.velY*=Math.pow(u,i);const M=n.posX+r-h,b=n.posY+c-l;if(!f){const t=d/Math.hypot(M,b);m<t?(n.offX+=(t*M-M-n.offX)*v,n.offY+=(t*b-b-n.offY)*v):(n.offX-=n.offX*v,n.offY-=n.offY*v)}n.x=n.posX+n.offX,n.y=n.posY+n.offY,g&&(n.posX=n.x,n.posY=n.y),n.x+=r,n.y+=c,this.#o(n),n.isVisible=1===n.gridPos.x&&1===n.gridPos.y}}#o(t){t.gridPos.x=+(t.x>=t.bounds.left)+ +(t.x>t.bounds.right),t.gridPos.y=+(t.y>=t.bounds.top)+ +(t.y>t.bounds.bottom)}#a(t,i){return!(!t.isVisible&&!i.isVisible)||!(t.gridPos.x===i.gridPos.x&&1!==t.gridPos.x||t.gridPos.y===i.gridPos.y&&1!==t.gridPos.y)}#r(){const i=this.particles,e=i.length,s=this.ctx;for(let n=0;n<e;n++){const e=i[n];e.isVisible&&(e.size>1?(s.beginPath(),s.arc(e.x,e.y,e.size,0,t),s.fill(),s.closePath()):s.fillRect(e.x-e.size,e.y-e.size,2*e.size,2*e.size))}}#c(){const t=this.particles,i=t.length,e=this.ctx,s=this.option.particles.connectDist,n=s**2,o=(s/2)**2,a=s>=Math.min(this.canvas.width,this.canvas.height),r=n*this.option.particles.maxWork,c=this.color.alpha,h=this.color.alpha*s,l=[];for(let s=0;s<i;s++){const p=t[s];let u=0;for(let d=s+1;d<i;d++){const i=t[d];if(!a&&!this.#a(p,i))continue;const s=p.x-i.x,m=p.y-i.y,f=s*s+m*m;if(!(f>n)&&(f>o?(e.globalAlpha=h/Math.sqrt(f)-c,e.beginPath(),e.moveTo(p.x,p.y),e.lineTo(i.x,i.y),e.stroke()):l.push([p.x,p.y,i.x,i.y]),(u+=f)>=r))break}}if(l.length){e.globalAlpha=c,e.beginPath();for(let t=0;t<l.length;t++){const i=l[t];e.moveTo(i[0],i[1]),e.lineTo(i[2],i[3])}e.stroke()}}#t(){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.#r(),this.options.particles.drawLines&&this.#c()}#h(){if(!this.isAnimating)return;requestAnimationFrame(()=>this.#h());const t=performance.now(),i=Math.min(t-this.lastAnimationFrame,e.MAX_DT)/e.BASE_DT;this.#s(i),this.#n(i),this.#t(),this.lastAnimationFrame=t}start({auto:t=!1}={}){return this.isAnimating||t&&!this.enableAnimating||(this.enableAnimating=!0,this.isAnimating=!0,this.updateCanvasRect(),requestAnimationFrame(()=>this.#h())),!this.canvas.inViewbox&&this.option.animation.startOnEnter&&(this.isAnimating=!1),this}stop({auto:t=!1,clear:i=!0}={}){return t||(this.enableAnimating=!1),this.isAnimating=!1,!1!==i&&this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height),!0}destroy(){this.stop(),e.canvasIntersectionObserver.unobserve(this.canvas),e.canvasResizeObserver.unobserve(this.canvas),window.removeEventListener("mousemove",this.handleMouseMove),window.removeEventListener("scroll",this.handleScroll),this.canvas?.remove(),Object.keys(this).forEach(t=>delete this[t])}set options(t){const i=e.parseNumericOption;this.option={background:t.background??!1,animation:{startOnEnter:!!(t.animation?.startOnEnter??1),stopOnLeave:!!(t.animation?.stopOnLeave??1)},mouse:{interactionType:~~i("mouse.interactionType",t.mouse?.interactionType,e.interactionType.MOVE,{min:0,max:2}),connectDistMult:i("mouse.connectDistMult",t.mouse?.connectDistMult,2/3,{min:0}),connectDist:1,distRatio:i("mouse.distRatio",t.mouse?.distRatio,2/3,{min:0})},particles:{generationType:~~i("particles.generationType",t.particles?.generationType,e.generationType.MATCH,{min:0,max:2}),drawLines:!!(t.particles?.drawLines??1),color:t.particles?.color??"black",ppm:~~i("particles.ppm",t.particles?.ppm,100),max:Math.round(i("particles.max",t.particles?.max,1/0,{min:0})),maxWork:Math.round(i("particles.maxWork",t.particles?.maxWork,1/0,{min:0})),connectDist:~~i("particles.connectDistance",t.particles?.connectDistance,150,{min:1}),relSpeed:i("particles.relSpeed",t.particles?.relSpeed,1,{min:0}),relSize:i("particles.relSize",t.particles?.relSize,1,{min:0}),rotationSpeed:i("particles.rotationSpeed",t.particles?.rotationSpeed,2,{min:0})/100},gravity:{repulsive:i("gravity.repulsive",t.gravity?.repulsive,0,{min:0}),pulling:i("gravity.pulling",t.gravity?.pulling,0,{min:0}),friction:i("gravity.friction",t.gravity?.friction,.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}setBackground(t){if(t){if("string"!=typeof t)throw new TypeError("background is not a string");this.canvas.style.background=this.option.background=t}}setMouseConnectDistMult(t){const i=e.parseNumericOption("mouse.connectDistMult",t,2/3,{min:0});this.option.mouse.connectDist=this.option.particles.connectDist*i}setParticleColor(t){if(this.ctx.fillStyle=t,"#"===String(this.ctx.fillStyle)[0])this.color={hex:String(this.ctx.fillStyle),alpha:1};else{let t=String(this.ctx.fillStyle).split(",").at(-1);t=t?.slice(1,-1)??"1",this.ctx.fillStyle=String(this.ctx.fillStyle).split(",").slice(0,-1).join(",")+", 1)",this.color={hex:String(this.ctx.fillStyle),alpha:isNaN(+t)?1:+t}}}}return e});
1
+ !function(t,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(t="undefined"!=typeof globalThis?globalThis:t||self).CanvasParticles=i()}(this,function(){"use strict";const t=2*Math.PI;const i=function(t){let i=t>>>0;return{next(){let t=i+1831565813|0;return i=t,t=Math.imul(t^t>>>15,1|t),t^=t+Math.imul(t^t>>>7,61|t),((t^t>>>14)>>>0)/4294967296}}}(Math.random()*2**32).next;class e{static version="4.3.1";static MAX_DT=20;static BASE_DT=1e3/60;static interactionType=Object.freeze({NONE:0,SHIFT:1,MOVE:2});static generationType=Object.freeze({MANUAL:0,NEW:1,MATCH:2});static canvasIntersectionObserver=new IntersectionObserver(t=>{for(let i=0;i<t.length;i++){const e=t[i],s=e.target,n=s.instance;if(!n.options?.animation)return;(s.inViewbox=e.isIntersecting)?n.options.animation?.startOnEnter&&n.start({auto:!0}):n.options.animation?.stopOnLeave&&n.stop({auto:!0,clear:!1})}},{rootMargin:"-1px"});static canvasResizeObserver=new ResizeObserver(t=>{for(let i=0;i<t.length;i++){t[i].target.instance.updateCanvasRect()}for(let i=0;i<t.length;i++){t[i].target.instance.resizeCanvas()}});static defaultIfNaN=(t,i)=>isNaN(+t)?i:+t;static parseNumericOption=(t,i,s,n)=>{if(null==i)return s;const{min:o=-1/0,max:a=1/0}=n??{};return i<o?console.warn(new RangeError(`option.${t} was clamped to ${o} as ${i} is too low`)):i>a&&console.warn(new RangeError(`option.${t} was clamped to ${a} as ${i} is too high`)),e.defaultIfNaN(Math.min(Math.max(i??s,o),a),s)};canvas;ctx;enableAnimating=!1;isAnimating=!1;lastAnimationFrame=0;particles=[];hasManualParticles=!1;clientX=1/0;clientY=1/0;mouseX=1/0;mouseY=1/0;width;height;offX;offY;option;color;constructor(t,i={}){let s;if(t instanceof HTMLCanvasElement)s=t;else{if("string"!=typeof t)throw new TypeError("selector is not a string and neither a HTMLCanvasElement itself");if(s=document.querySelector(t),!(s instanceof HTMLCanvasElement))throw new Error("selector does not point to a canvas")}this.canvas=s,this.canvas.instance=this,this.canvas.inViewbox=!0;const n=this.canvas.getContext("2d");if(!n)throw new Error("failed to get 2D context from canvas");this.ctx=n,this.options=i,e.canvasIntersectionObserver.observe(this.canvas),e.canvasResizeObserver.observe(this.canvas),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:!0}),window.addEventListener("scroll",this.handleScroll,{passive:!0})}updateCanvasRect(){const{top:t,left:i,width:e,height:s}=this.canvas.getBoundingClientRect();this.canvas.rect={top:t,left:i,width:e,height:s}}handleMouseMove(t){this.enableAnimating&&(this.clientX=t.clientX,this.clientY=t.clientY,this.isAnimating&&this.updateMousePos())}handleScroll(){this.enableAnimating&&(this.updateCanvasRect(),this.isAnimating&&this.updateMousePos())}updateMousePos(){const{top:t,left:i}=this.canvas.rect;this.mouseX=this.clientX-i,this.mouseY=this.clientY-t}resizeCanvas(){const t=this.canvas.width=this.canvas.rect.width,i=this.canvas.height=this.canvas.rect.height;this.mouseX=1/0,this.mouseY=1/0,this.width=Math.max(t+2*this.option.particles.connectDist,1),this.height=Math.max(i+2*this.option.particles.connectDist,1),this.offX=(t-this.width)/2,this.offY=(i-this.height)/2;const s=this.option.particles.generationType;s!==e.generationType.MANUAL&&(s===e.generationType.NEW||0===this.particles.length?this.newParticles():s===e.generationType.MATCH&&this.matchParticleCount({updateBounds:!0})),this.isAnimating&&this.#t()}#i(){let t=Math.round(this.option.particles.ppm*this.width*this.height/1e6);if(t=Math.min(this.option.particles.max,t),!isFinite(t))throw new RangeError("particleCount must be finite");return 0|t}newParticles(){const t=this.#i();this.hasManualParticles?(this.particles=this.particles.filter(t=>t.manual),this.hasManualParticles=this.particles.length>0):this.particles=[];for(let i=0;i<t;i++)this.#e()}matchParticleCount({updateBounds:t=!1}={}){const i=this.#i();if(this.hasManualParticles){const t=[];let e=0;for(const s of this.particles){if(e>=i)break;s.manual&&e++,t.push(s)}this.particles=t}else this.particles=this.particles.slice(0,i);if(t)for(const t of this.particles)this.#s(t);for(let t=this.particles.length;t<i;t++)this.#e()}#e(){const e=i()*this.width,s=i()*this.height,n={posX:e,posY:s,x:e,y:s,velX:0,velY:0,offX:0,offY:0,dir:i()*t,speed:(.5+.5*i())*this.option.particles.relSpeed,size:(.5+2*Math.pow(i(),5))*this.option.particles.relSize,gridPos:{x:1,y:1},isVisible:!1,manual:!1};this.#s(n),this.particles.push(n)}createParticle(t,i,e,s,n){const o={posX:t,posY:i,x:t,y:i,velX:0,velY:0,offX:0,offY:0,dir:e,speed:s,size:n,gridPos:{x:1,y:1},isVisible:!1,manual:!0};this.#s(o),this.particles.push(o),this.hasManualParticles=!0}#s(t){t.bounds={top:-t.size,right:this.canvas.width+t.size,bottom:this.canvas.height+t.size,left:-t.size}}updateParticles(){const t=this.particles,e=t.length,s=this.option.particles.relSpeed,n=this.option.particles.relSize;for(let o=0;o<e;o++){const e=t[o];e.speed=(.5+.5*i())*s,e.size=(.5+2*Math.pow(i(),5))*n}}#n(t){const i=this.option.gravity.repulsive>0,e=this.option.gravity.pulling>0;if(!i&&!e)return;const s=this.particles,n=s.length,o=this.option.particles.connectDist,a=o*this.option.gravity.repulsive*t,r=o*this.option.gravity.pulling*t,c=(o/2)**2,l=o**2/256;for(let t=0;t<n;t++){const i=s[t];for(let o=t+1;o<n;o++){const t=s[o],n=i.posX-t.posX,h=i.posY-t.posY,p=n*n+h*h;if(p>=c&&!e)continue;let u,d,m;u=Math.atan2(-h,-n),d=1/(p+l);const f=Math.cos(u),g=Math.sin(u);if(p<c){m=d*a;const e=f*m,s=g*m;i.velX-=e,i.velY-=s,t.velX+=e,t.velY+=s}if(!e)continue;m=d*r;const v=f*m,y=g*m;i.velX+=v,i.velY+=y,t.velX-=v,t.velY-=y}}}#o(i){const s=this.particles,n=s.length,o=this.width,a=this.height,r=this.offX,c=this.offY,l=this.mouseX,h=this.mouseY,p=this.option.particles.rotationSpeed*i,u=this.option.gravity.friction,d=this.option.mouse.connectDist,m=this.option.mouse.distRatio,f=this.option.mouse.interactionType===e.interactionType.NONE,g=this.option.mouse.interactionType===e.interactionType.MOVE,v=1-Math.pow(.75,i);for(let e=0;e<n;e++){const n=s[e];n.dir+=2*(Math.random()-.5)*p*i,n.dir%=t;const y=Math.sin(n.dir)*n.speed,x=Math.cos(n.dir)*n.speed;n.posX+=(y+n.velX)*i,n.posY+=(x+n.velY)*i,n.posX%=o,n.posX<0&&(n.posX+=o),n.posY%=a,n.posY<0&&(n.posY+=a),n.velX*=Math.pow(u,i),n.velY*=Math.pow(u,i);const M=n.posX+r-l,b=n.posY+c-h;if(!f){const t=d/Math.hypot(M,b);m<t?(n.offX+=(t*M-M-n.offX)*v,n.offY+=(t*b-b-n.offY)*v):(n.offX-=n.offX*v,n.offY-=n.offY*v)}n.x=n.posX+n.offX,n.y=n.posY+n.offY,g&&(n.posX=n.x,n.posY=n.y),n.x+=r,n.y+=c,this.#a(n),n.isVisible=1===n.gridPos.x&&1===n.gridPos.y}}#a(t){t.gridPos.x=+(t.x>=t.bounds.left)+ +(t.x>t.bounds.right),t.gridPos.y=+(t.y>=t.bounds.top)+ +(t.y>t.bounds.bottom)}#r(t,i){return!(!t.isVisible&&!i.isVisible)||!(t.gridPos.x===i.gridPos.x&&1!==t.gridPos.x||t.gridPos.y===i.gridPos.y&&1!==t.gridPos.y)}#c(){const i=this.particles,e=i.length,s=this.ctx;for(let n=0;n<e;n++){const e=i[n];e.isVisible&&(e.size>1?(s.beginPath(),s.arc(e.x,e.y,e.size,0,t),s.fill(),s.closePath()):s.fillRect(e.x-e.size,e.y-e.size,2*e.size,2*e.size))}}#l(){const t=this.particles,i=t.length,e=this.ctx,s=this.option.particles.connectDist,n=s**2,o=(s/2)**2,a=s>=Math.min(this.canvas.width,this.canvas.height),r=n*this.option.particles.maxWork,c=this.color.alpha,l=this.color.alpha*s,h=[];for(let s=0;s<i;s++){const p=t[s];let u=0;for(let d=s+1;d<i;d++){const i=t[d];if(!a&&!this.#r(p,i))continue;const s=p.x-i.x,m=p.y-i.y,f=s*s+m*m;if(!(f>n)&&(f>o?(e.globalAlpha=l/Math.sqrt(f)-c,e.beginPath(),e.moveTo(p.x,p.y),e.lineTo(i.x,i.y),e.stroke()):h.push([p.x,p.y,i.x,i.y]),(u+=f)>=r))break}}if(h.length){e.globalAlpha=c,e.beginPath();for(let t=0;t<h.length;t++){const i=h[t];e.moveTo(i[0],i[1]),e.lineTo(i[2],i[3])}e.stroke()}}#t(){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.#c(),this.options.particles.drawLines&&this.#l()}#h(){if(!this.isAnimating)return;requestAnimationFrame(()=>this.#h());const t=performance.now(),i=Math.min(t-this.lastAnimationFrame,e.MAX_DT)/e.BASE_DT;this.#n(i),this.#o(i),this.#t(),this.lastAnimationFrame=t}start({auto:t=!1}={}){return this.isAnimating||t&&!this.enableAnimating||(this.enableAnimating=!0,this.isAnimating=!0,this.updateCanvasRect(),requestAnimationFrame(()=>this.#h())),!this.canvas.inViewbox&&this.option.animation.startOnEnter&&(this.isAnimating=!1),this}stop({auto:t=!1,clear:i=!0}={}){return t||(this.enableAnimating=!1),this.isAnimating=!1,!1!==i&&this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height),!0}destroy(){this.stop(),e.canvasIntersectionObserver.unobserve(this.canvas),e.canvasResizeObserver.unobserve(this.canvas),window.removeEventListener("mousemove",this.handleMouseMove),window.removeEventListener("scroll",this.handleScroll),this.canvas?.remove(),Object.keys(this).forEach(t=>delete this[t])}set options(t){const i=e.parseNumericOption;this.option={background:t.background??!1,animation:{startOnEnter:!!(t.animation?.startOnEnter??1),stopOnLeave:!!(t.animation?.stopOnLeave??1)},mouse:{interactionType:~~i("mouse.interactionType",t.mouse?.interactionType,e.interactionType.MOVE,{min:0,max:2}),connectDistMult:i("mouse.connectDistMult",t.mouse?.connectDistMult,2/3,{min:0}),connectDist:1,distRatio:i("mouse.distRatio",t.mouse?.distRatio,2/3,{min:0})},particles:{generationType:~~i("particles.generationType",t.particles?.generationType,e.generationType.MATCH,{min:0,max:2}),drawLines:!!(t.particles?.drawLines??1),color:t.particles?.color??"black",ppm:~~i("particles.ppm",t.particles?.ppm,100),max:Math.round(i("particles.max",t.particles?.max,1/0,{min:0})),maxWork:Math.round(i("particles.maxWork",t.particles?.maxWork,1/0,{min:0})),connectDist:~~i("particles.connectDistance",t.particles?.connectDistance,150,{min:1}),relSpeed:i("particles.relSpeed",t.particles?.relSpeed,1,{min:0}),relSize:i("particles.relSize",t.particles?.relSize,1,{min:0}),rotationSpeed:i("particles.rotationSpeed",t.particles?.rotationSpeed,2,{min:0})/100},gravity:{repulsive:i("gravity.repulsive",t.gravity?.repulsive,0,{min:0}),pulling:i("gravity.pulling",t.gravity?.pulling,0,{min:0}),friction:i("gravity.friction",t.gravity?.friction,.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}setBackground(t){if(t){if("string"!=typeof t)throw new TypeError("background is not a string");this.canvas.style.background=this.option.background=t}}setMouseConnectDistMult(t){const i=e.parseNumericOption("mouse.connectDistMult",t,2/3,{min:0});this.option.mouse.connectDist=this.option.particles.connectDist*i}setParticleColor(t){if(this.ctx.fillStyle=t,"#"===String(this.ctx.fillStyle)[0])this.color={hex:String(this.ctx.fillStyle),alpha:1};else{let t=String(this.ctx.fillStyle).split(",").at(-1);t=t?.slice(1,-1)??"1",this.ctx.fillStyle=String(this.ctx.fillStyle).split(",").slice(0,-1).join(",")+", 1)",this.color={hex:String(this.ctx.fillStyle),alpha:isNaN(+t)?1:+t}}}}return e});
@@ -27,6 +27,7 @@ export interface Particle {
27
27
  bounds: ParticleBounds;
28
28
  gridPos: ParticleGridPos;
29
29
  isVisible: boolean;
30
+ manual: boolean;
30
31
  }
31
32
  export interface ParticleBounds {
32
33
  top: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvasparticles-js",
3
- "version": "4.3.0",
3
+ "version": "4.3.1",
4
4
  "description": "In an HTML canvas, a bunch of interactive particles connected with lines when they approach each other.",
5
5
  "author": "Khoeckman",
6
6
  "license": "MIT",
@@ -30,6 +30,7 @@
30
30
  "@rollup/plugin-terser": "^0.4.4",
31
31
  "@rollup/plugin-typescript": "^12.3.0",
32
32
  "@types/node": "^24.10.4",
33
+ "pnpm": "^10.27.0",
33
34
  "prettier": "^3.7.4",
34
35
  "rollup": "^4.54.0",
35
36
  "rollup-plugin-delete": "^3.0.2",
package/src/index.ts CHANGED
@@ -100,9 +100,9 @@ export default class CanvasParticles {
100
100
 
101
101
  const { min = -Infinity, max = Infinity } = clamp ?? {}
102
102
 
103
- if (isFinite(min) && value < min) {
103
+ if (value < min) {
104
104
  console.warn(new RangeError(`option.${name} was clamped to ${min} as ${value} is too low`))
105
- } else if (isFinite(max) && value > max) {
105
+ } else if (value > max) {
106
106
  console.warn(new RangeError(`option.${name} was clamped to ${max} as ${value} is too high`))
107
107
  }
108
108
 
@@ -117,6 +117,7 @@ export default class CanvasParticles {
117
117
  private lastAnimationFrame: number = 0
118
118
 
119
119
  particles: Particle[] = []
120
+ hasManualParticles = false // set to true once @public createParticle() is used
120
121
  private clientX: number = Infinity
121
122
  private clientY: number = Infinity
122
123
  mouseX: number = Infinity
@@ -239,24 +240,50 @@ export default class CanvasParticles {
239
240
  newParticles() {
240
241
  const particleCount = this.#targetParticleCount()
241
242
 
242
- this.particles = []
243
- for (let i = 0; i < particleCount; i++) this.createParticle()
243
+ if (this.hasManualParticles) {
244
+ this.particles = this.particles.filter((particle) => particle.manual)
245
+ this.hasManualParticles = this.particles.length > 0
246
+ } else {
247
+ this.particles = []
248
+ }
249
+
250
+ for (let i = 0; i < particleCount; i++) this.#createParticle()
244
251
  }
245
252
 
246
253
  /** @public Adjust particle array length to match `options.particles.ppm` */
247
254
  matchParticleCount({ updateBounds = false }: { updateBounds?: boolean } = {}) {
248
255
  const particleCount = this.#targetParticleCount()
249
256
 
250
- this.particles = this.particles.slice(0, particleCount)
251
- if (updateBounds) this.particles.forEach((particle) => this.#updateParticleBounds(particle))
257
+ if (this.hasManualParticles) {
258
+ const pruned: Particle[] = []
259
+ let autoCount = 0
260
+
261
+ // Keep manual particles while pruning automatic particles that exceed `particleCount`
262
+ // Only count automatic particles towards `particledCount`
263
+ for (const particle of this.particles) {
264
+ if (autoCount >= particleCount) break
265
+ if (particle.manual) autoCount++
266
+ pruned.push(particle)
267
+ }
268
+ this.particles = pruned
269
+ } else {
270
+ this.particles = this.particles.slice(0, particleCount)
271
+ }
272
+
273
+ // Only necessary after resize
274
+ if (updateBounds) {
275
+ for (const particle of this.particles) {
276
+ this.#updateParticleBounds(particle)
277
+ }
278
+ }
252
279
 
253
- while (particleCount > this.particles.length) this.createParticle()
280
+ for (let i = this.particles.length; i < particleCount; i++) this.#createParticle()
254
281
  }
255
282
 
256
- /** @public Create a new particle with optional parameters */
257
- createParticle(posX?: number, posY?: number, dir?: number, speed?: number, size?: number) {
258
- posX = typeof posX === 'number' ? posX - this.offX : prng() * this.width
259
- posY = typeof posY === 'number' ? posY - this.offY : prng() * this.height
283
+ /** @private Create a random new particle */
284
+ #createParticle() {
285
+ const posX = prng() * this.width
286
+ const posY = prng() * this.height
260
287
 
261
288
  const particle: Omit<Particle, 'bounds'> = {
262
289
  posX, // Logical position in pixels
@@ -267,18 +294,44 @@ export default class CanvasParticles {
267
294
  velY: 0, // Vertical speed in pixels per update
268
295
  offX: 0, // Horizontal distance from drawn to logical position in pixels
269
296
  offY: 0, // Vertical distance from drawn to logical position in pixels
270
- dir: dir ?? prng() * TWO_PI, // Direction in radians
271
- speed: speed ?? (0.5 + prng() * 0.5) * this.option.particles.relSpeed, // Velocity in pixels per update
272
- size: size ?? (0.5 + Math.pow(prng(), 5) * 2) * this.option.particles.relSize, // Ray in pixels of the particle
297
+ dir: prng() * TWO_PI, // Direction in radians
298
+ speed: (0.5 + prng() * 0.5) * this.option.particles.relSpeed, // Velocity in pixels per update
299
+ size: (0.5 + Math.pow(prng(), 5) * 2) * this.option.particles.relSize, // Ray in pixels of the particle
300
+ gridPos: { x: 1, y: 1 },
301
+ isVisible: false,
302
+ manual: false,
303
+ }
304
+ this.#updateParticleBounds(particle)
305
+ this.particles.push(particle)
306
+ }
307
+
308
+ /** @public Create a new particle with optional parameters */
309
+ createParticle(posX: number, posY: number, dir: number, speed: number, size: number) {
310
+ const particle: Omit<Particle, 'bounds'> = {
311
+ posX, // Logical position in pixels
312
+ posY, // Logical position in pixels
313
+ x: posX, // Visual position in pixels
314
+ y: posY, // Visual position in pixels
315
+ velX: 0, // Horizonal speed in pixels per update
316
+ velY: 0, // Vertical speed in pixels per update
317
+ offX: 0, // Horizontal distance from drawn to logical position in pixels
318
+ offY: 0, // Vertical distance from drawn to logical position in pixels
319
+ dir: dir, // Direction in radians
320
+ speed: speed, // Velocity in pixels per update
321
+ size: size, // Ray in pixels of the particle
273
322
  gridPos: { x: 1, y: 1 },
274
323
  isVisible: false,
324
+ manual: true,
275
325
  }
276
326
  this.#updateParticleBounds(particle)
277
- this.particles.push(particle as Particle)
327
+ this.particles.push(particle)
328
+ this.hasManualParticles = true
278
329
  }
279
330
 
280
331
  /** @private Update the visible bounds of a particle */
281
- #updateParticleBounds(particle: Omit<Particle, 'bounds'> & Partial<Pick<Particle, 'bounds'>>) {
332
+ #updateParticleBounds(
333
+ particle: Omit<Particle, 'bounds'> & Partial<Pick<Particle, 'bounds'>>
334
+ ): asserts particle is Particle {
282
335
  // The particle is considered visible within these bounds
283
336
  particle.bounds = {
284
337
  top: -particle.size,
@@ -30,6 +30,7 @@ export interface Particle {
30
30
  bounds: ParticleBounds
31
31
  gridPos: ParticleGridPos
32
32
  isVisible: boolean
33
+ manual: boolean
33
34
  }
34
35
 
35
36
  export interface ParticleBounds {