canvasparticles-js 4.3.0 → 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.0";
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',
@@ -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;
@@ -183,7 +184,7 @@ class CanvasParticles {
183
184
  if (this.isAnimating)
184
185
  this.#render();
185
186
  }
186
- /** @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`. */
187
188
  #targetParticleCount() {
188
189
  // Amount of particles to be created
189
190
  let particleCount = Math.round((this.option.particles.ppm * this.width * this.height) / 1_000_000);
@@ -193,25 +194,58 @@ class CanvasParticles {
193
194
  return particleCount | 0;
194
195
  }
195
196
  /** @public Remove existing particles and generate new ones */
196
- newParticles() {
197
+ newParticles({ keepAuto = false, keepManual = true } = {}) {
197
198
  const particleCount = this.#targetParticleCount();
198
- this.particles = [];
199
+ if (this.hasManualParticles && (keepAuto || keepManual)) {
200
+ this.particles = this.particles.filter((particle) => (keepAuto && !particle.isManual) || (keepManual && particle.isManual));
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
- /** @public Adjust particle array length to match `options.particles.ppm` */
209
+ /** @public Adjust particle array length to match `option.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 (particle.isManual) {
219
+ pruned.push(particle);
220
+ continue;
221
+ }
222
+ if (autoCount >= particleCount)
223
+ continue;
224
+ pruned.push(particle);
225
+ autoCount++;
226
+ }
227
+ this.particles = pruned;
228
+ }
229
+ else {
230
+ this.particles = this.particles.slice(0, particleCount);
231
+ }
232
+ // Only necessary after resize
233
+ if (updateBounds) {
234
+ for (const particle of this.particles) {
235
+ this.#updateParticleBounds(particle);
236
+ }
237
+ }
238
+ for (let i = this.particles.length; i < particleCount; i++)
239
+ this.#createParticle();
240
+ }
241
+ /** @private Create a random new particle */
242
+ #createParticle() {
243
+ const posX = prng() * this.width;
244
+ const posY = prng() * this.height;
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);
210
246
  }
211
247
  /** @public Create a new particle with optional parameters */
212
- 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;
248
+ createParticle(posX, posY, dir, speed, size, isManual = true) {
215
249
  const particle = {
216
250
  posX, // Logical position in pixels
217
251
  posY, // Logical position in pixels
@@ -221,14 +255,16 @@ class CanvasParticles {
221
255
  velY: 0, // Vertical speed in pixels per update
222
256
  offX: 0, // Horizontal distance from drawn to logical position in pixels
223
257
  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
258
+ dir: dir, // Direction in radians
259
+ speed: speed, // Velocity in pixels per update
260
+ size: size, // Ray in pixels of the particle
227
261
  gridPos: { x: 1, y: 1 },
228
262
  isVisible: false,
263
+ isManual,
229
264
  };
230
265
  this.#updateParticleBounds(particle);
231
266
  this.particles.push(particle);
267
+ this.hasManualParticles = true;
232
268
  }
233
269
  /** @private Update the visible bounds of a particle */
234
270
  #updateParticleBounds(particle) {
@@ -242,14 +278,12 @@ class CanvasParticles {
242
278
  }
243
279
  /* @public Randomize speed and size of all particles based on current options */
244
280
  updateParticles() {
245
- const particles = this.particles;
246
- const len = particles.length;
247
281
  const relSpeed = this.option.particles.relSpeed;
248
282
  const relSize = this.option.particles.relSize;
249
- for (let i = 0; i < len; i++) {
250
- const particle = particles[i];
283
+ for (const particle of this.particles) {
251
284
  particle.speed = (0.5 + prng() * 0.5) * relSpeed;
252
285
  particle.size = (0.5 + Math.pow(prng(), 5) * 2) * relSize;
286
+ this.#updateParticleBounds(particle); // because size changed
253
287
  }
254
288
  }
255
289
  /** @private Apply gravity forces between particles */
@@ -395,7 +429,7 @@ class CanvasParticles {
395
429
  // Visible if either particle is in the center
396
430
  if (particleA.isVisible || particleB.isVisible)
397
431
  return true;
398
- // 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
399
433
  return !((particleA.gridPos.x === particleB.gridPos.x && particleA.gridPos.x !== 1) ||
400
434
  (particleA.gridPos.y === particleB.gridPos.y && particleA.gridPos.y !== 1));
401
435
  }
@@ -489,7 +523,7 @@ class CanvasParticles {
489
523
  this.ctx.strokeStyle = this.color.hex;
490
524
  this.ctx.lineWidth = 1;
491
525
  this.#renderParticles();
492
- if (this.options.particles.drawLines)
526
+ if (this.option.particles.drawLines)
493
527
  this.#renderConnections();
494
528
  }
495
529
  /** @private Main animation loop that updates and renders the particles */
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;
@@ -53,13 +54,16 @@ export default class CanvasParticles {
53
54
  /** @public Resize the canvas and update particles accordingly */
54
55
  resizeCanvas(): void;
55
56
  /** @public Remove existing particles and generate new ones */
56
- newParticles(): void;
57
- /** @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` */
58
62
  matchParticleCount({ updateBounds }?: {
59
63
  updateBounds?: boolean;
60
64
  }): void;
61
65
  /** @public Create a new particle with optional parameters */
62
- 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;
63
67
  updateParticles(): void;
64
68
  /** @public Start the particle animation if it was not running before */
65
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.0";
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',
@@ -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;
@@ -181,7 +182,7 @@ class CanvasParticles {
181
182
  if (this.isAnimating)
182
183
  this.#render();
183
184
  }
184
- /** @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`. */
185
186
  #targetParticleCount() {
186
187
  // Amount of particles to be created
187
188
  let particleCount = Math.round((this.option.particles.ppm * this.width * this.height) / 1_000_000);
@@ -191,25 +192,58 @@ class CanvasParticles {
191
192
  return particleCount | 0;
192
193
  }
193
194
  /** @public Remove existing particles and generate new ones */
194
- newParticles() {
195
+ newParticles({ keepAuto = false, keepManual = true } = {}) {
195
196
  const particleCount = this.#targetParticleCount();
196
- this.particles = [];
197
+ if (this.hasManualParticles && (keepAuto || keepManual)) {
198
+ this.particles = this.particles.filter((particle) => (keepAuto && !particle.isManual) || (keepManual && particle.isManual));
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
- /** @public Adjust particle array length to match `options.particles.ppm` */
207
+ /** @public Adjust particle array length to match `option.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 (particle.isManual) {
217
+ pruned.push(particle);
218
+ continue;
219
+ }
220
+ if (autoCount >= particleCount)
221
+ continue;
222
+ pruned.push(particle);
223
+ autoCount++;
224
+ }
225
+ this.particles = pruned;
226
+ }
227
+ else {
228
+ this.particles = this.particles.slice(0, particleCount);
229
+ }
230
+ // Only necessary after resize
231
+ if (updateBounds) {
232
+ for (const particle of this.particles) {
233
+ this.#updateParticleBounds(particle);
234
+ }
235
+ }
236
+ for (let i = this.particles.length; i < particleCount; i++)
237
+ this.#createParticle();
238
+ }
239
+ /** @private Create a random new particle */
240
+ #createParticle() {
241
+ const posX = prng() * this.width;
242
+ const posY = prng() * this.height;
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);
208
244
  }
209
245
  /** @public Create a new particle with optional parameters */
210
- 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;
246
+ createParticle(posX, posY, dir, speed, size, isManual = true) {
213
247
  const particle = {
214
248
  posX, // Logical position in pixels
215
249
  posY, // Logical position in pixels
@@ -219,14 +253,16 @@ class CanvasParticles {
219
253
  velY: 0, // Vertical speed in pixels per update
220
254
  offX: 0, // Horizontal distance from drawn to logical position in pixels
221
255
  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
256
+ dir: dir, // Direction in radians
257
+ speed: speed, // Velocity in pixels per update
258
+ size: size, // Ray in pixels of the particle
225
259
  gridPos: { x: 1, y: 1 },
226
260
  isVisible: false,
261
+ isManual,
227
262
  };
228
263
  this.#updateParticleBounds(particle);
229
264
  this.particles.push(particle);
265
+ this.hasManualParticles = true;
230
266
  }
231
267
  /** @private Update the visible bounds of a particle */
232
268
  #updateParticleBounds(particle) {
@@ -240,14 +276,12 @@ class CanvasParticles {
240
276
  }
241
277
  /* @public Randomize speed and size of all particles based on current options */
242
278
  updateParticles() {
243
- const particles = this.particles;
244
- const len = particles.length;
245
279
  const relSpeed = this.option.particles.relSpeed;
246
280
  const relSize = this.option.particles.relSize;
247
- for (let i = 0; i < len; i++) {
248
- const particle = particles[i];
281
+ for (const particle of this.particles) {
249
282
  particle.speed = (0.5 + prng() * 0.5) * relSpeed;
250
283
  particle.size = (0.5 + Math.pow(prng(), 5) * 2) * relSize;
284
+ this.#updateParticleBounds(particle); // because size changed
251
285
  }
252
286
  }
253
287
  /** @private Apply gravity forces between particles */
@@ -393,7 +427,7 @@ class CanvasParticles {
393
427
  // Visible if either particle is in the center
394
428
  if (particleA.isVisible || particleB.isVisible)
395
429
  return true;
396
- // 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
397
431
  return !((particleA.gridPos.x === particleB.gridPos.x && particleA.gridPos.x !== 1) ||
398
432
  (particleA.gridPos.y === particleB.gridPos.y && particleA.gridPos.y !== 1));
399
433
  }
@@ -487,7 +521,7 @@ class CanvasParticles {
487
521
  this.ctx.strokeStyle = this.color.hex;
488
522
  this.ctx.lineWidth = 1;
489
523
  this.#renderParticles();
490
- if (this.options.particles.drawLines)
524
+ if (this.option.particles.drawLines)
491
525
  this.#renderConnections();
492
526
  }
493
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.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.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,6 +27,7 @@ export interface Particle {
27
27
  bounds: ParticleBounds;
28
28
  gridPos: ParticleGridPos;
29
29
  isVisible: boolean;
30
+ isManual: 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.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,9 +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",
32
+ "@types/node": "^24.10.6",
33
+ "pnpm": "^10.28.0",
33
34
  "prettier": "^3.7.4",
34
- "rollup": "^4.54.0",
35
+ "rollup": "^4.55.1",
35
36
  "rollup-plugin-delete": "^3.0.2",
36
37
  "tslib": "^2.8.1",
37
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
  {
@@ -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
@@ -225,7 +226,7 @@ export default class CanvasParticles {
225
226
  if (this.isAnimating) this.#render()
226
227
  }
227
228
 
228
- /** @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`. */
229
230
  #targetParticleCount(): number {
230
231
  // Amount of particles to be created
231
232
  let particleCount = Math.round((this.option.particles.ppm * this.width * this.height) / 1_000_000)
@@ -236,28 +237,73 @@ export default class CanvasParticles {
236
237
  }
237
238
 
238
239
  /** @public Remove existing particles and generate new ones */
239
- newParticles() {
240
+ newParticles({ keepAuto = false, keepManual = true } = {}) {
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 && (keepAuto || keepManual)) {
244
+ this.particles = this.particles.filter(
245
+ (particle) => (keepAuto && !particle.isManual) || (keepManual && particle.isManual)
246
+ )
247
+ this.hasManualParticles = this.particles.length > 0
248
+ } else {
249
+ this.particles = []
250
+ }
251
+
252
+ for (let i = 0; i < particleCount; i++) this.#createParticle()
244
253
  }
245
254
 
246
- /** @public Adjust particle array length to match `options.particles.ppm` */
255
+ /** @public Adjust particle array length to match `option.particles.ppm` */
247
256
  matchParticleCount({ updateBounds = false }: { updateBounds?: boolean } = {}) {
248
257
  const particleCount = this.#targetParticleCount()
249
258
 
250
- this.particles = this.particles.slice(0, particleCount)
251
- if (updateBounds) this.particles.forEach((particle) => this.#updateParticleBounds(particle))
259
+ if (this.hasManualParticles) {
260
+ const pruned: Particle[] = []
261
+ let autoCount = 0
262
+
263
+ // Keep manual particles while pruning automatic particles that exceed `particleCount`
264
+ // Only count automatic particles towards `particledCount`
265
+ for (const particle of this.particles) {
266
+ if (particle.isManual) {
267
+ pruned.push(particle)
268
+ continue
269
+ }
270
+
271
+ if (autoCount >= particleCount) continue
272
+ pruned.push(particle)
273
+ autoCount++
274
+ }
275
+ this.particles = pruned
276
+ } else {
277
+ this.particles = this.particles.slice(0, particleCount)
278
+ }
279
+
280
+ // Only necessary after resize
281
+ if (updateBounds) {
282
+ for (const particle of this.particles) {
283
+ this.#updateParticleBounds(particle)
284
+ }
285
+ }
252
286
 
253
- while (particleCount > this.particles.length) this.createParticle()
287
+ for (let i = this.particles.length; i < particleCount; i++) this.#createParticle()
254
288
  }
255
289
 
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
290
+ /** @private Create a random new particle */
291
+ #createParticle() {
292
+ const posX = prng() * this.width
293
+ const posY = prng() * this.height
294
+
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
+ )
303
+ }
260
304
 
305
+ /** @public Create a new particle with optional parameters */
306
+ createParticle(posX: number, posY: number, dir: number, speed: number, size: number, isManual = true) {
261
307
  const particle: Omit<Particle, 'bounds'> = {
262
308
  posX, // Logical position in pixels
263
309
  posY, // Logical position in pixels
@@ -267,18 +313,22 @@ export default class CanvasParticles {
267
313
  velY: 0, // Vertical speed in pixels per update
268
314
  offX: 0, // Horizontal distance from drawn to logical position in pixels
269
315
  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
316
+ dir: dir, // Direction in radians
317
+ speed: speed, // Velocity in pixels per update
318
+ size: size, // Ray in pixels of the particle
273
319
  gridPos: { x: 1, y: 1 },
274
320
  isVisible: false,
321
+ isManual,
275
322
  }
276
323
  this.#updateParticleBounds(particle)
277
- this.particles.push(particle as Particle)
324
+ this.particles.push(particle)
325
+ this.hasManualParticles = true
278
326
  }
279
327
 
280
328
  /** @private Update the visible bounds of a particle */
281
- #updateParticleBounds(particle: Omit<Particle, 'bounds'> & Partial<Pick<Particle, 'bounds'>>) {
329
+ #updateParticleBounds(
330
+ particle: Omit<Particle, 'bounds'> & Partial<Pick<Particle, 'bounds'>>
331
+ ): asserts particle is Particle {
282
332
  // The particle is considered visible within these bounds
283
333
  particle.bounds = {
284
334
  top: -particle.size,
@@ -290,15 +340,13 @@ export default class CanvasParticles {
290
340
 
291
341
  /* @public Randomize speed and size of all particles based on current options */
292
342
  updateParticles() {
293
- const particles = this.particles
294
- const len = particles.length
295
343
  const relSpeed = this.option.particles.relSpeed
296
344
  const relSize = this.option.particles.relSize
297
345
 
298
- for (let i = 0; i < len; i++) {
299
- const particle = particles[i]
346
+ for (const particle of this.particles) {
300
347
  particle.speed = (0.5 + prng() * 0.5) * relSpeed
301
348
  particle.size = (0.5 + Math.pow(prng(), 5) * 2) * relSize
349
+ this.#updateParticleBounds(particle) // because size changed
302
350
  }
303
351
  }
304
352
 
@@ -466,7 +514,7 @@ export default class CanvasParticles {
466
514
  // Visible if either particle is in the center
467
515
  if (particleA.isVisible || particleB.isVisible) return true
468
516
 
469
- // 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
470
518
  return !(
471
519
  (particleA.gridPos.x === particleB.gridPos.x && particleA.gridPos.x !== 1) ||
472
520
  (particleA.gridPos.y === particleB.gridPos.y && particleA.gridPos.y !== 1)
@@ -576,7 +624,7 @@ export default class CanvasParticles {
576
624
  this.ctx.lineWidth = 1
577
625
 
578
626
  this.#renderParticles()
579
- if (this.options.particles.drawLines) this.#renderConnections()
627
+ if (this.option.particles.drawLines) this.#renderConnections()
580
628
  }
581
629
 
582
630
  /** @private Main animation loop that updates and renders the particles */
@@ -30,6 +30,7 @@ export interface Particle {
30
30
  bounds: ParticleBounds
31
31
  gridPos: ParticleGridPos
32
32
  isVisible: boolean
33
+ isManual: boolean
33
34
  }
34
35
 
35
36
  export interface ParticleBounds {