canvasparticles-js 4.3.1 → 4.3.2

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/README.md CHANGED
@@ -337,16 +337,19 @@ By default `particles.ppm` and `particles.max` are used to auto-generate random
337
337
  const canvas = '#my-canvas'
338
338
  const options = {
339
339
  particles: {
340
- generationType: CanvasParticles.generationType.MANUAL, // = 0
340
+ max: 0,
341
341
  rotationSpeed: 0,
342
342
  },
343
343
  }
344
344
  const instance = new CanvasParticles(canvas, options).start()
345
345
 
346
346
  // Create a horizontal line of particles moving down
347
- for (let x = 100; x < 300; x += 4) {
347
+ for (let x = 0; x < instance.width; x += 4) {
348
348
  instance.createParticle(x, 100, 0, 1, 5)
349
349
  }
350
+
351
+ // Keep automatically generated particles and remove manually created ones
352
+ instance.newParticles({ keepAuto: true, keepManual: false })
350
353
  ```
351
354
 
352
355
  ---
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.1";
24
+ static version = "4.3.2";
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 */
@@ -45,9 +45,9 @@ class CanvasParticles {
45
45
  if (!instance.options?.animation)
46
46
  return;
47
47
  if ((canvas.inViewbox = entry.isIntersecting))
48
- instance.options.animation?.startOnEnter && instance.start({ auto: true });
48
+ instance.option.animation?.startOnEnter && instance.start({ auto: true });
49
49
  else
50
- instance.options.animation?.stopOnLeave && instance.stop({ auto: true, clear: false });
50
+ instance.option.animation?.stopOnLeave && instance.stop({ auto: true, clear: false });
51
51
  }
52
52
  }, {
53
53
  rootMargin: '-1px',
@@ -184,7 +184,7 @@ class CanvasParticles {
184
184
  if (this.isAnimating)
185
185
  this.#render();
186
186
  }
187
- /** @private Update the target number of particles based on the current canvas size and `options.particles.ppm`, capped at `options.particles.max`. */
187
+ /** @private Update the target number of particles based on the current canvas size and `option.particles.ppm`, capped at `option.particles.max`. */
188
188
  #targetParticleCount() {
189
189
  // Amount of particles to be created
190
190
  let particleCount = Math.round((this.option.particles.ppm * this.width * this.height) / 1_000_000);
@@ -194,10 +194,10 @@ class CanvasParticles {
194
194
  return particleCount | 0;
195
195
  }
196
196
  /** @public Remove existing particles and generate new ones */
197
- newParticles() {
197
+ newParticles({ keepAuto = false, keepManual = true } = {}) {
198
198
  const particleCount = this.#targetParticleCount();
199
- if (this.hasManualParticles) {
200
- this.particles = this.particles.filter((particle) => particle.manual);
199
+ if (this.hasManualParticles && (keepAuto || keepManual)) {
200
+ this.particles = this.particles.filter((particle) => (keepAuto && !particle.isManual) || (keepManual && particle.isManual));
201
201
  this.hasManualParticles = this.particles.length > 0;
202
202
  }
203
203
  else {
@@ -206,7 +206,7 @@ class CanvasParticles {
206
206
  for (let i = 0; i < particleCount; i++)
207
207
  this.#createParticle();
208
208
  }
209
- /** @public Adjust particle array length to match `options.particles.ppm` */
209
+ /** @public Adjust particle array length to match `option.particles.ppm` */
210
210
  matchParticleCount({ updateBounds = false } = {}) {
211
211
  const particleCount = this.#targetParticleCount();
212
212
  if (this.hasManualParticles) {
@@ -215,11 +215,14 @@ class CanvasParticles {
215
215
  // Keep manual particles while pruning automatic particles that exceed `particleCount`
216
216
  // Only count automatic particles towards `particledCount`
217
217
  for (const particle of this.particles) {
218
+ if (particle.isManual) {
219
+ pruned.push(particle);
220
+ continue;
221
+ }
218
222
  if (autoCount >= particleCount)
219
- break;
220
- if (particle.manual)
221
- autoCount++;
223
+ continue;
222
224
  pruned.push(particle);
225
+ autoCount++;
223
226
  }
224
227
  this.particles = pruned;
225
228
  }
@@ -239,27 +242,10 @@ class CanvasParticles {
239
242
  #createParticle() {
240
243
  const posX = prng() * this.width;
241
244
  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);
245
+ 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);
260
246
  }
261
247
  /** @public Create a new particle with optional parameters */
262
- createParticle(posX, posY, dir, speed, size) {
248
+ createParticle(posX, posY, dir, speed, size, isManual = true) {
263
249
  const particle = {
264
250
  posX, // Logical position in pixels
265
251
  posY, // Logical position in pixels
@@ -274,7 +260,7 @@ class CanvasParticles {
274
260
  size: size, // Ray in pixels of the particle
275
261
  gridPos: { x: 1, y: 1 },
276
262
  isVisible: false,
277
- manual: true,
263
+ isManual,
278
264
  };
279
265
  this.#updateParticleBounds(particle);
280
266
  this.particles.push(particle);
@@ -292,14 +278,12 @@ class CanvasParticles {
292
278
  }
293
279
  /* @public Randomize speed and size of all particles based on current options */
294
280
  updateParticles() {
295
- const particles = this.particles;
296
- const len = particles.length;
297
281
  const relSpeed = this.option.particles.relSpeed;
298
282
  const relSize = this.option.particles.relSize;
299
- for (let i = 0; i < len; i++) {
300
- const particle = particles[i];
283
+ for (const particle of this.particles) {
301
284
  particle.speed = (0.5 + prng() * 0.5) * relSpeed;
302
285
  particle.size = (0.5 + Math.pow(prng(), 5) * 2) * relSize;
286
+ this.#updateParticleBounds(particle); // because size changed
303
287
  }
304
288
  }
305
289
  /** @private Apply gravity forces between particles */
@@ -445,7 +429,7 @@ class CanvasParticles {
445
429
  // Visible if either particle is in the center
446
430
  if (particleA.isVisible || particleB.isVisible)
447
431
  return true;
448
- // Not visible if both particles are in the same vertical or horizontal line but outside the center
432
+ // Not visible if both particles are in the same vertical or horizontal line that does not cross the center
449
433
  return !((particleA.gridPos.x === particleB.gridPos.x && particleA.gridPos.x !== 1) ||
450
434
  (particleA.gridPos.y === particleB.gridPos.y && particleA.gridPos.y !== 1));
451
435
  }
@@ -539,7 +523,7 @@ class CanvasParticles {
539
523
  this.ctx.strokeStyle = this.color.hex;
540
524
  this.ctx.lineWidth = 1;
541
525
  this.#renderParticles();
542
- if (this.options.particles.drawLines)
526
+ if (this.option.particles.drawLines)
543
527
  this.#renderConnections();
544
528
  }
545
529
  /** @private Main animation loop that updates and renders the particles */
package/dist/index.d.ts CHANGED
@@ -54,13 +54,16 @@ export default class CanvasParticles {
54
54
  /** @public Resize the canvas and update particles accordingly */
55
55
  resizeCanvas(): void;
56
56
  /** @public Remove existing particles and generate new ones */
57
- newParticles(): void;
58
- /** @public Adjust particle array length to match `options.particles.ppm` */
57
+ newParticles({ keepAuto, keepManual }?: {
58
+ keepAuto?: boolean | undefined;
59
+ keepManual?: boolean | undefined;
60
+ }): void;
61
+ /** @public Adjust particle array length to match `option.particles.ppm` */
59
62
  matchParticleCount({ updateBounds }?: {
60
63
  updateBounds?: boolean;
61
64
  }): void;
62
65
  /** @public Create a new particle with optional parameters */
63
- createParticle(posX: number, posY: number, dir: number, speed: number, size: number): void;
66
+ createParticle(posX: number, posY: number, dir: number, speed: number, size: number, isManual?: boolean): void;
64
67
  updateParticles(): void;
65
68
  /** @public Start the particle animation if it was not running before */
66
69
  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.1";
22
+ static version = "4.3.2";
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 */
@@ -43,9 +43,9 @@ class CanvasParticles {
43
43
  if (!instance.options?.animation)
44
44
  return;
45
45
  if ((canvas.inViewbox = entry.isIntersecting))
46
- instance.options.animation?.startOnEnter && instance.start({ auto: true });
46
+ instance.option.animation?.startOnEnter && instance.start({ auto: true });
47
47
  else
48
- instance.options.animation?.stopOnLeave && instance.stop({ auto: true, clear: false });
48
+ instance.option.animation?.stopOnLeave && instance.stop({ auto: true, clear: false });
49
49
  }
50
50
  }, {
51
51
  rootMargin: '-1px',
@@ -182,7 +182,7 @@ class CanvasParticles {
182
182
  if (this.isAnimating)
183
183
  this.#render();
184
184
  }
185
- /** @private Update the target number of particles based on the current canvas size and `options.particles.ppm`, capped at `options.particles.max`. */
185
+ /** @private Update the target number of particles based on the current canvas size and `option.particles.ppm`, capped at `option.particles.max`. */
186
186
  #targetParticleCount() {
187
187
  // Amount of particles to be created
188
188
  let particleCount = Math.round((this.option.particles.ppm * this.width * this.height) / 1_000_000);
@@ -192,10 +192,10 @@ class CanvasParticles {
192
192
  return particleCount | 0;
193
193
  }
194
194
  /** @public Remove existing particles and generate new ones */
195
- newParticles() {
195
+ newParticles({ keepAuto = false, keepManual = true } = {}) {
196
196
  const particleCount = this.#targetParticleCount();
197
- if (this.hasManualParticles) {
198
- this.particles = this.particles.filter((particle) => particle.manual);
197
+ if (this.hasManualParticles && (keepAuto || keepManual)) {
198
+ this.particles = this.particles.filter((particle) => (keepAuto && !particle.isManual) || (keepManual && particle.isManual));
199
199
  this.hasManualParticles = this.particles.length > 0;
200
200
  }
201
201
  else {
@@ -204,7 +204,7 @@ class CanvasParticles {
204
204
  for (let i = 0; i < particleCount; i++)
205
205
  this.#createParticle();
206
206
  }
207
- /** @public Adjust particle array length to match `options.particles.ppm` */
207
+ /** @public Adjust particle array length to match `option.particles.ppm` */
208
208
  matchParticleCount({ updateBounds = false } = {}) {
209
209
  const particleCount = this.#targetParticleCount();
210
210
  if (this.hasManualParticles) {
@@ -213,11 +213,14 @@ class CanvasParticles {
213
213
  // Keep manual particles while pruning automatic particles that exceed `particleCount`
214
214
  // Only count automatic particles towards `particledCount`
215
215
  for (const particle of this.particles) {
216
+ if (particle.isManual) {
217
+ pruned.push(particle);
218
+ continue;
219
+ }
216
220
  if (autoCount >= particleCount)
217
- break;
218
- if (particle.manual)
219
- autoCount++;
221
+ continue;
220
222
  pruned.push(particle);
223
+ autoCount++;
221
224
  }
222
225
  this.particles = pruned;
223
226
  }
@@ -237,27 +240,10 @@ class CanvasParticles {
237
240
  #createParticle() {
238
241
  const posX = prng() * this.width;
239
242
  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);
243
+ 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);
258
244
  }
259
245
  /** @public Create a new particle with optional parameters */
260
- createParticle(posX, posY, dir, speed, size) {
246
+ createParticle(posX, posY, dir, speed, size, isManual = true) {
261
247
  const particle = {
262
248
  posX, // Logical position in pixels
263
249
  posY, // Logical position in pixels
@@ -272,7 +258,7 @@ class CanvasParticles {
272
258
  size: size, // Ray in pixels of the particle
273
259
  gridPos: { x: 1, y: 1 },
274
260
  isVisible: false,
275
- manual: true,
261
+ isManual,
276
262
  };
277
263
  this.#updateParticleBounds(particle);
278
264
  this.particles.push(particle);
@@ -290,14 +276,12 @@ class CanvasParticles {
290
276
  }
291
277
  /* @public Randomize speed and size of all particles based on current options */
292
278
  updateParticles() {
293
- const particles = this.particles;
294
- const len = particles.length;
295
279
  const relSpeed = this.option.particles.relSpeed;
296
280
  const relSize = this.option.particles.relSize;
297
- for (let i = 0; i < len; i++) {
298
- const particle = particles[i];
281
+ for (const particle of this.particles) {
299
282
  particle.speed = (0.5 + prng() * 0.5) * relSpeed;
300
283
  particle.size = (0.5 + Math.pow(prng(), 5) * 2) * relSize;
284
+ this.#updateParticleBounds(particle); // because size changed
301
285
  }
302
286
  }
303
287
  /** @private Apply gravity forces between particles */
@@ -443,7 +427,7 @@ class CanvasParticles {
443
427
  // Visible if either particle is in the center
444
428
  if (particleA.isVisible || particleB.isVisible)
445
429
  return true;
446
- // Not visible if both particles are in the same vertical or horizontal line but outside the center
430
+ // Not visible if both particles are in the same vertical or horizontal line that does not cross the center
447
431
  return !((particleA.gridPos.x === particleB.gridPos.x && particleA.gridPos.x !== 1) ||
448
432
  (particleA.gridPos.y === particleB.gridPos.y && particleA.gridPos.y !== 1));
449
433
  }
@@ -537,7 +521,7 @@ class CanvasParticles {
537
521
  this.ctx.strokeStyle = this.color.hex;
538
522
  this.ctx.lineWidth = 1;
539
523
  this.#renderParticles();
540
- if (this.options.particles.drawLines)
524
+ if (this.option.particles.drawLines)
541
525
  this.#renderConnections();
542
526
  }
543
527
  /** @private Main animation loop that updates and renders the particles */
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.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});
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.2";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.option.animation?.startOnEnter&&n.start({auto:!0}):n.option.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({keepAuto:t=!1,keepManual:i=!0}={}){const e=this.#i();this.hasManualParticles&&(t||i)?(this.particles=this.particles.filter(e=>t&&!e.isManual||i&&e.isManual),this.hasManualParticles=this.particles.length>0):this.particles=[];for(let t=0;t<e;t++)this.#e()}matchParticleCount({updateBounds:t=!1}={}){const i=this.#i();if(this.hasManualParticles){const t=[];let e=0;for(const s of this.particles)s.isManual?t.push(s):e>=i||(t.push(s),e++);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;this.createParticle(e,s,i()*t,(.5+.5*i())*this.option.particles.relSpeed,(.5+2*Math.pow(i(),5))*this.option.particles.relSize,!1)}createParticle(t,i,e,s,n,o=!0){const a={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,isManual:o};this.#s(a),this.particles.push(a),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.option.particles.relSpeed,e=this.option.particles.relSize;for(const s of this.particles)s.speed=(.5+.5*i())*t,s.size=(.5+2*Math.pow(i(),5))*e,this.#s(s)}#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,M=Math.cos(n.dir)*n.speed;n.posX+=(y+n.velX)*i,n.posY+=(M+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 x=n.posX+r-l,b=n.posY+c-h;if(!f){const t=d/Math.hypot(x,b);m<t?(n.offX+=(t*x-x-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.option.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,7 +27,7 @@ export interface Particle {
27
27
  bounds: ParticleBounds;
28
28
  gridPos: ParticleGridPos;
29
29
  isVisible: boolean;
30
- manual: boolean;
30
+ isManual: boolean;
31
31
  }
32
32
  export interface ParticleBounds {
33
33
  top: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvasparticles-js",
3
- "version": "4.3.1",
3
+ "version": "4.3.2",
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",
@@ -29,10 +29,10 @@
29
29
  "@rollup/plugin-replace": "^6.0.3",
30
30
  "@rollup/plugin-terser": "^0.4.4",
31
31
  "@rollup/plugin-typescript": "^12.3.0",
32
- "@types/node": "^24.10.4",
33
- "pnpm": "^10.27.0",
32
+ "@types/node": "^24.10.6",
33
+ "pnpm": "^10.28.0",
34
34
  "prettier": "^3.7.4",
35
- "rollup": "^4.54.0",
35
+ "rollup": "^4.55.1",
36
36
  "rollup-plugin-delete": "^3.0.2",
37
37
  "tslib": "^2.8.1",
38
38
  "typescript": "^5.9.3"
package/src/index.ts CHANGED
@@ -59,8 +59,8 @@ export default class CanvasParticles {
59
59
  if (!instance.options?.animation) return
60
60
 
61
61
  if ((canvas.inViewbox = entry.isIntersecting))
62
- instance.options.animation?.startOnEnter && instance.start({ auto: true })
63
- else instance.options.animation?.stopOnLeave && instance.stop({ auto: true, clear: false })
62
+ instance.option.animation?.startOnEnter && instance.start({ auto: true })
63
+ else instance.option.animation?.stopOnLeave && instance.stop({ auto: true, clear: false })
64
64
  }
65
65
  },
66
66
  {
@@ -226,7 +226,7 @@ export default class CanvasParticles {
226
226
  if (this.isAnimating) this.#render()
227
227
  }
228
228
 
229
- /** @private Update the target number of particles based on the current canvas size and `options.particles.ppm`, capped at `options.particles.max`. */
229
+ /** @private Update the target number of particles based on the current canvas size and `option.particles.ppm`, capped at `option.particles.max`. */
230
230
  #targetParticleCount(): number {
231
231
  // Amount of particles to be created
232
232
  let particleCount = Math.round((this.option.particles.ppm * this.width * this.height) / 1_000_000)
@@ -237,11 +237,13 @@ export default class CanvasParticles {
237
237
  }
238
238
 
239
239
  /** @public Remove existing particles and generate new ones */
240
- newParticles() {
240
+ newParticles({ keepAuto = false, keepManual = true } = {}) {
241
241
  const particleCount = this.#targetParticleCount()
242
242
 
243
- if (this.hasManualParticles) {
244
- this.particles = this.particles.filter((particle) => particle.manual)
243
+ if (this.hasManualParticles && (keepAuto || keepManual)) {
244
+ this.particles = this.particles.filter(
245
+ (particle) => (keepAuto && !particle.isManual) || (keepManual && particle.isManual)
246
+ )
245
247
  this.hasManualParticles = this.particles.length > 0
246
248
  } else {
247
249
  this.particles = []
@@ -250,7 +252,7 @@ export default class CanvasParticles {
250
252
  for (let i = 0; i < particleCount; i++) this.#createParticle()
251
253
  }
252
254
 
253
- /** @public Adjust particle array length to match `options.particles.ppm` */
255
+ /** @public Adjust particle array length to match `option.particles.ppm` */
254
256
  matchParticleCount({ updateBounds = false }: { updateBounds?: boolean } = {}) {
255
257
  const particleCount = this.#targetParticleCount()
256
258
 
@@ -261,9 +263,14 @@ export default class CanvasParticles {
261
263
  // Keep manual particles while pruning automatic particles that exceed `particleCount`
262
264
  // Only count automatic particles towards `particledCount`
263
265
  for (const particle of this.particles) {
264
- if (autoCount >= particleCount) break
265
- if (particle.manual) autoCount++
266
+ if (particle.isManual) {
267
+ pruned.push(particle)
268
+ continue
269
+ }
270
+
271
+ if (autoCount >= particleCount) continue
266
272
  pruned.push(particle)
273
+ autoCount++
267
274
  }
268
275
  this.particles = pruned
269
276
  } else {
@@ -285,28 +292,18 @@ export default class CanvasParticles {
285
292
  const posX = prng() * this.width
286
293
  const posY = prng() * this.height
287
294
 
288
- const particle: Omit<Particle, 'bounds'> = {
289
- posX, // Logical position in pixels
290
- posY, // Logical position in pixels
291
- x: posX, // Visual position in pixels
292
- y: posY, // Visual position in pixels
293
- velX: 0, // Horizonal speed in pixels per update
294
- velY: 0, // Vertical speed in pixels per update
295
- offX: 0, // Horizontal distance from drawn to logical position in pixels
296
- offY: 0, // Vertical distance from drawn to logical position in pixels
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)
295
+ this.createParticle(
296
+ posX,
297
+ posY,
298
+ prng() * TWO_PI,
299
+ (0.5 + prng() * 0.5) * this.option.particles.relSpeed,
300
+ (0.5 + Math.pow(prng(), 5) * 2) * this.option.particles.relSize,
301
+ false
302
+ )
306
303
  }
307
304
 
308
305
  /** @public Create a new particle with optional parameters */
309
- createParticle(posX: number, posY: number, dir: number, speed: number, size: number) {
306
+ createParticle(posX: number, posY: number, dir: number, speed: number, size: number, isManual = true) {
310
307
  const particle: Omit<Particle, 'bounds'> = {
311
308
  posX, // Logical position in pixels
312
309
  posY, // Logical position in pixels
@@ -321,7 +318,7 @@ export default class CanvasParticles {
321
318
  size: size, // Ray in pixels of the particle
322
319
  gridPos: { x: 1, y: 1 },
323
320
  isVisible: false,
324
- manual: true,
321
+ isManual,
325
322
  }
326
323
  this.#updateParticleBounds(particle)
327
324
  this.particles.push(particle)
@@ -343,15 +340,13 @@ export default class CanvasParticles {
343
340
 
344
341
  /* @public Randomize speed and size of all particles based on current options */
345
342
  updateParticles() {
346
- const particles = this.particles
347
- const len = particles.length
348
343
  const relSpeed = this.option.particles.relSpeed
349
344
  const relSize = this.option.particles.relSize
350
345
 
351
- for (let i = 0; i < len; i++) {
352
- const particle = particles[i]
346
+ for (const particle of this.particles) {
353
347
  particle.speed = (0.5 + prng() * 0.5) * relSpeed
354
348
  particle.size = (0.5 + Math.pow(prng(), 5) * 2) * relSize
349
+ this.#updateParticleBounds(particle) // because size changed
355
350
  }
356
351
  }
357
352
 
@@ -519,7 +514,7 @@ export default class CanvasParticles {
519
514
  // Visible if either particle is in the center
520
515
  if (particleA.isVisible || particleB.isVisible) return true
521
516
 
522
- // Not visible if both particles are in the same vertical or horizontal line but outside the center
517
+ // Not visible if both particles are in the same vertical or horizontal line that does not cross the center
523
518
  return !(
524
519
  (particleA.gridPos.x === particleB.gridPos.x && particleA.gridPos.x !== 1) ||
525
520
  (particleA.gridPos.y === particleB.gridPos.y && particleA.gridPos.y !== 1)
@@ -629,7 +624,7 @@ export default class CanvasParticles {
629
624
  this.ctx.lineWidth = 1
630
625
 
631
626
  this.#renderParticles()
632
- if (this.options.particles.drawLines) this.#renderConnections()
627
+ if (this.option.particles.drawLines) this.#renderConnections()
633
628
  }
634
629
 
635
630
  /** @private Main animation loop that updates and renders the particles */
@@ -30,7 +30,7 @@ export interface Particle {
30
30
  bounds: ParticleBounds
31
31
  gridPos: ParticleGridPos
32
32
  isVisible: boolean
33
- manual: boolean
33
+ isManual: boolean
34
34
  }
35
35
 
36
36
  export interface ParticleBounds {