canvasparticles-js 4.1.5 → 4.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,19 +11,25 @@ In an HTML canvas, a bunch of floating particles connected with lines when they
11
11
  Creating a fun and interactive background. Colors, interaction and gravity can be customized!
12
12
 
13
13
  [Showcase](#showcase)<br>
14
+ [Import](#import)<br>
14
15
  [Implementation](#implementation)<br>
15
- [Class instantiation](#class-instantiation)<br>
16
+ [Class Instantiation](#class-instantiation)<br>
16
17
  [Options](#options)<br>
17
- [One pager example](#one-pager-example)
18
+ [One Pager Example](#one-pager-example)
19
+
20
+ ---
18
21
 
19
22
  ## Showcase
20
23
 
21
24
  If you dont like reading documentation this website is for you:<br>
25
+
22
26
  [https://khoeckman.github.io/canvasparticles-js/](https://khoeckman.github.io/canvasparticles-js/)
23
27
 
24
- ![Banner with particles and title: Canvas Particles](./demo/banner.webp)
28
+ [![Banner with particles and title: Canvas Particles](./demo/banner.webp)](https://khoeckman.github.io/canvasparticles-js/)
25
29
 
26
- ## Implementation
30
+ ---
31
+
32
+ ## Import
27
33
 
28
34
  Particles will be drawn onto a `<canvas>` element.
29
35
 
@@ -31,107 +37,51 @@ Particles will be drawn onto a `<canvas>` element.
31
37
  <canvas id="my-canvas"></canvas>
32
38
  ```
33
39
 
34
- <details open>
35
- <summary><h3 style="display: inline;">Import with npm</h3></summary>
40
+ ### npm
36
41
 
37
42
  ```bash
38
43
  npm install canvasparticles-js
39
44
  # or
40
45
  pnpm add canvasparticles-js
41
- # or
42
- yarn add canvasparticles-js
43
- ```
44
-
45
- Add a `<script>` element in the `<head>` to import _initParticles.js_.
46
-
47
- ```html
48
- <head>
49
- <script src="./initParticles.js" type="module"></script>
50
- </head>
51
46
  ```
52
47
 
53
- Inside _initParticles.js_:
48
+ **ES Module import**
54
49
 
55
50
  ```js
56
51
  import CanvasParticles from 'canvasparticles-js'
57
-
58
- const selector = '#my-canvas' // Query Selector for the canvas
59
- const options = { ... } // See #options
60
- new CanvasParticles(selector, options).start()
61
52
  ```
62
53
 
63
- </details>
54
+ If you don't have a bundler:
64
55
 
65
- <details>
66
- <summary><h3 style="display: inline;">Import with jsDelivr (click to expand)</h3></summary>
56
+ ```js
57
+ import CanvasParticles from './node_modules/canvasparticles-js/dist/index.mjs'
58
+ ```
67
59
 
68
- Add a `<script>` element in the `<head>` to import `CanvasParticles`.
60
+ **Global import**
69
61
 
70
62
  ```html
71
- <head>
72
- <script src="https://cdn.jsdelivr.net/npm/canvasparticles-js/dist/index.umd.js" defer></script>
73
- </head>
63
+ <script src="./node_modules/canvasparticles-js/dist/index.umd.js" defer></script>
74
64
  ```
75
65
 
76
- </details>
77
-
78
- <details>
79
- <summary><h3 style="display: inline;">Import raw file as ES module (click to expand)</h3></summary>
66
+ No import required in each JavaScript file!
80
67
 
81
- Be aware that using ES modules is only possible when running the application on a (local) server.<br>
82
- [Same Origin Policy](https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy)
68
+ ### Import with jsDelivr
83
69
 
84
- Add a `<script>` element in the `<head>` to import _initParticles.js_.
85
-
86
- ```html
87
- <head>
88
- <script src="./initParticles.js" type="module"></script>
89
- </head>
90
- ```
91
-
92
- Inside _initParticles.js_:
70
+ **ES Module import**
93
71
 
94
72
  ```js
95
- import CanvasParticles from './dist/index.mjs'
96
-
97
- const selector = '#my-canvas' // Query Selector for the canvas
98
- const options = { ... } // See #options
99
- new CanvasParticles(selector, options).start()
73
+ import CanvasParticles from 'https://cdn.jsdelivr.net/npm/canvasparticles-js/dist/index.min.mjs'
100
74
  ```
101
75
 
102
- </details>
103
-
104
- <details>
105
- <summary><h3 style="display: inline;">Import raw file globally (click to expand)</h3></summary>
106
-
107
- Add a `<script>` element in the `<head>` to import the [dist/index.umd.js](https://github.com/Khoeckman/canvasparticles-js/blob/main/dist/index.umd.js) file.<br>
108
- ```html
109
- <head>
110
- <script src="./index.umd.js" defer></script>
111
- </head>
112
- ```
113
-
114
- Add an inline `<script>` element at the very bottom of the `<body>`.
76
+ **Global import**
115
77
 
116
78
  ```html
117
- <body>
118
- ...
119
-
120
- <script>
121
- const initParticles = () => {
122
- const selector = '#my-canvas' // Query Selector for the canvas
123
- const options = { ... } // See #options
124
- new CanvasParticles(selector, options).start()
125
- }
126
- document.addEventListener('DOMContentLoaded', initParticles)
127
- </script>
128
- </body>
79
+ <script src="https://cdn.jsdelivr.net/npm/canvasparticles-js/dist/index.umd.min.js" defer></script>
129
80
  ```
130
81
 
131
- </details>
82
+ ---
132
83
 
133
- <br>
134
- <br>
84
+ ## Implementation
135
85
 
136
86
  ### Start animating
137
87
 
@@ -164,7 +114,9 @@ particles.destroy()
164
114
  delete particles // Optional
165
115
  ```
166
116
 
167
- ## Class instantiation
117
+ ---
118
+
119
+ ## Class Instantiation
168
120
 
169
121
  ### Valid ways to instantiate `CanvasParticles`
170
122
 
@@ -217,137 +169,87 @@ canvas = new CanvasParticles(selector).anyOtherMethod().canvas
217
169
 
218
170
  ## Options
219
171
 
220
- Configuration options for the particles and their behavior.<br>
221
- Play around with these values: [Sandbox](https://khoeckman.github.io/canvasparticles-js/#sandbox)
172
+ Options to change the particles and their behavior aswell as what happens on `MouseMove` or `Resize` events.<br>
173
+ Play around with these values in the [Sandbox](https://khoeckman.github.io/canvasparticles-js/#sandbox).
222
174
 
223
- <details>
224
- <summary><h3 style="display: inline;">Options structure (click to expand)</h3></summary>
175
+ ### Options Object
225
176
 
226
177
  The default value will be used when an option is assigned an invalid value.<br>
227
178
  Your screen resolution and refresh rate will directly impact perfomance!
228
179
 
229
- ```js
230
- const options = {
231
- /** @param {string} [options.background=false] - Background of the canvas. Can be any CSS supported value for the background property.
232
- * @note No background will be set if background is not a string.
233
- */
234
- background: 'linear-gradient(115deg, #354089, black)',
235
-
236
- /** @param {Object} [options.animation] - Animation settings. */
237
- animation: {
238
- /** @param {boolean} [options.animation.startOnEnter=true] - Whether to start the animation when the canvas enters the viewport. */
239
- startOnEnter: true,
240
-
241
- /** @param {boolean} [options.animation.stopOnLeave=true] - Whether to stop the animation when the canvas leaves the viewport. */
242
- stopOnLeave: true,
243
- },
244
-
245
- /** @param {Object} [options.mouse] - Mouse interaction settings. */
246
- mouse: {
247
- /** @param {0|1|2} [options.mouse.interactionType=1] - The type of interaction the mouse will have with particles.
248
- *
249
- * CanvasParticles.interactionType.NONE = 0 = No interaction.
250
- * CanvasParticles.interactionType.SHIFT = 1 = The mouse can visually shift the particles.
251
- * CanvasParticles.interactionType.MOVE = 2 = The mouse can move the particles.
252
- * @note mouse.distRatio should be less than 1 to allow dragging, closer to 0 is easier to drag
253
- */
254
- interactionType: 2,
255
-
256
- /** @param {float} [options.mouse.connectDistMult=2÷3] - The maximum distance for the mouse to interact with the particles.
257
- * The value is multiplied by particles.connectDistance
258
- * @example 0.8 connectDistMult * 150 particles.connectDistance = 120 pixels
259
- */
260
- connectDistMult: 0.8,
261
-
262
- /** @param {number} [options.mouse.distRatio=2÷3] - All particles within set radius from the mouse will be drawn to mouse.connectDistance pixels from the mouse.
263
- * @example radius = 150 connectDistance / 0.4 distRatio = 375 pixels
264
- * @note Keep this value above mouse.connectDistMult
265
- */
266
- distRatio: 1, // recommended: 0.2 - 1
267
- },
268
-
269
- /** @param {Object} [options.particles] - Particle settings. */
270
- particles: {
271
- /** param {string} [options.particles.color='black'] - The color of the particles and their connections. Can be any CSS supported color format. */
272
- color: '#88c8ffa0',
273
-
274
- /** @param {number} [options.particles.ppm=100] - Particles per million (ppm).
275
- * This determines how many particles are created per million pixels of the canvas.
276
- * @example FHD on Chrome = 1920 width * 937 height = 1799040 pixels; 1799040 pixels * 100 ppm / 1_000_000 = 179.904 = 179 particles
277
- * @important The amount of particles exponentially reduces performance.
278
- * People with large screens will have a bad experience with high values.
279
- * One solution is to increase particles.connectDistance and decrease this value.
280
- */
281
- ppm: 100, // recommended: < 120
282
-
283
- /** @param {number} [options.particles.max=500] - The maximum number of particles allowed. */
284
- max: 200, // recommended: < 500
285
-
286
- /** @param {number} [options.particles.maxWork=Infinity] - The maximum "work" a particle can perform before its connections are no longer drawn.
287
- * @example 10 maxWork = 10 * 150 connectDistance = max 1500 pixels of lines drawn per particle
288
- * @important Low values will stabilize performance at the cost of creating an ugly effect where connections may flicker.
289
- */
290
- maxWork: 10,
291
-
292
- /** @param {number} [options.particles.connectDistance=150] - The maximum distance for a connection between 2 particles.
293
- * @note Heavily affects performance. */
294
- connectDistance: 150,
295
-
296
- /** @param {number} [options.particles.relSpeed=1] - The relative moving speed of the particles.
297
- * The moving speed is a random value between 0.5 and 1 pixels per update multiplied by this value.
298
- */
299
- relSpeed: 0.8,
300
-
301
- /** @param {number} [options.particles.relSize=1] - The relative size of the particles.
302
- * The ray is a random value between 0.5 and 2.5 pixels multiplied by this value.
303
- */
304
- relSize: 1.1,
305
-
306
- /** @param {number} [options.particles.rotationSpeed=2] - The speed at which the particles randomly changes direction.
307
- * @example 1 rotationSpeed = max direction change of 0.01 radians per update
308
- */
309
- rotationSpeed: 1, // recommended: < 10
310
-
311
- /** @param {boolean} [options.particles.regenerateOnResize=false] - Create new particles when the canvas gets resized.
312
- * @note If false, will instead add or remove a few particles to match particles.ppm
313
- */
314
- regenerateOnResize: false,
315
- },
316
-
317
- /** @param {Object} [options.gravity] - Gravitational force settings.
318
- * @important Heavily reduces performance if gravity.repulsive or gravity.pulling is not equal to 0
319
- */
320
- gravity: {
321
- /** @param {number} [options.gravity.repulsive=0] - The repulsive force between particles. */
322
- repulsive: 2, // recommended: 0.50 - 5.00
323
-
324
- /** @param {number} [options.gravity.pulling=0] - The attractive force pulling particles together. Works poorly if `gravity.repulsive` is too low.
325
- * @note gravity.repulsive should be great enough to prevent forming a singularity.
326
- */
327
- pulling: 0.0, // recommended: 0.01 - 0.10
328
-
329
- /** @param {number} [options.gravity.friction=0.9] - The smoothness of the gravitational forces.
330
- * The force gets multiplied by the fricion every update.
331
- * @example force after x updates = force * friction ** x
332
- */
333
- friction: 0.8, // recommended: 0.500 - 0.999
334
- },
335
- }
336
- ```
180
+ ### Root options
181
+
182
+ | Option | Type | Default | Description |
183
+ | ------------ | ----------------- | ------- | ----------------------------------------------------------------------------- |
184
+ | `background` | `string \| false` | `false` | Canvas background. Any valid CSS `background` value. Ignored if not a string. |
185
+
186
+ ---
187
+
188
+ ### `animation`
189
+
190
+ | Option | Type | Default | Description |
191
+ | ------------------------ | --------- | ------- | ---------------------------------------------------- |
192
+ | `animation.startOnEnter` | `boolean` | `true` | Start animation when the canvas enters the viewport. |
193
+ | `animation.stopOnLeave` | `boolean` | `true` | Stop animation when the canvas leaves the viewport. |
194
+
195
+ ---
196
+
197
+ ### `mouse`
198
+
199
+ | Option | Type | Default | Description |
200
+ | ----------------------- | ------------- | ------- | ------------------------------------------------------------------------------------------------------------ |
201
+ | `mouse.interactionType` | `0 \| 1 \| 2` | `2` | Mouse interaction mode.<br>`0 = NONE`, `1 = SHIFT`, `2 = MOVE`. |
202
+ | `mouse.connectDistMult` | `float` | `2/3` | Multiplier applied to `particles.connectDistance` to compute mouse interaction distance. |
203
+ | `mouse.distRatio` | `float` | `2/3` | Controls how strongly particles are pulled toward the mouse. Keep equal to or above `mouse.connectDistMult`. |
204
+
205
+ **Interaction types** (enum)
337
206
 
338
- </details>
207
+ - `NONE (0)` – No interaction
208
+ - `SHIFT (1)` – Visual displacement only
209
+ - `MOVE (2)` – Actual particle movement
210
+
211
+ ---
212
+
213
+ ### `particles`
214
+
215
+ | Option | Type | Default | Description |
216
+ | ------------------------------ | --------- | ---------- | ------------------------------------------------------------------------------------------------- |
217
+ | `particles.color` | `string` | `'black'` | Particle and connection color. Any CSS color format. |
218
+ | `particles.ppm` | `integer` | `100` | Particles per million pixels. _Heavily impacts performance_ |
219
+ | `particles.max` | `integer` | `Infinity` | Maximum number of particles allowed. |
220
+ | `particles.maxWork` | `integer` | `Infinity` | Maximum total connection length per particle. Lower values stabilize performance but may flicker. |
221
+ | `particles.connectDistance` | `integer` | `150` | Maximum distance for particle connections (px). _Heavily impacts performance_ |
222
+ | `particles.relSpeed` | `float` | `1` | Relative particle speed multiplier. |
223
+ | `particles.relSize` | `float` | `1` | Relative particle size multiplier. |
224
+ | `particles.rotationSpeed` | `float` | `2` | Direction change speed. |
225
+ | `particles.regenerateOnResize` | `boolean` | `false` | Regenerate all particles when the canvas resizes. |
226
+ | `particles.drawLines` | `boolean` | `true` | Whether to draw lines between particles. |
227
+
228
+ ---
229
+
230
+ ### `gravity`
231
+
232
+ Enabling gravity (`repulsive` or `pulling` > 0) performs an extra **O(n²)** gravity computations per frame.
233
+
234
+ | Option | Type | Default | Description |
235
+ | ------------------- | ------- | ------- | -------------------------------------------------------------------------------------- |
236
+ | `gravity.repulsive` | `float` | `0` | Repulsive force between particles. Strongly impacts performance. |
237
+ | `gravity.pulling` | `float` | `0` | Attractive force between particles. Requires sufficient repulsion to avoid clustering. |
238
+ | `gravity.friction` | `float` | `0.8` | Damping factor applied to gravitational velocity each update (`0.0 – 1.0`). |
239
+
240
+ ---
339
241
 
340
242
  ### Update options on the fly
341
243
 
342
- **Note:** The new option values are not validated, except for the options with a `set...()` function. Assigning invalid values will lead to unexpected behavior.
244
+ You can update every option while an instance is animating and it works great; but some options require an extra step.
343
245
 
344
- #### Using the setter
246
+ #### Using the available setter
345
247
 
346
- These options require dedicated setters to ensure proper integration.
248
+ These options are the only ones that have and require a dedicated setter to ensure proper integration:
347
249
 
348
- - options.background
349
- - options.mouse.connectDistMult
350
- - options.particles.color
250
+ - `background`
251
+ - `mouse.connectDistMult`
252
+ - `particles.color`
351
253
 
352
254
  ```js
353
255
  const instance = new CanvasParticles(selector, options)
@@ -360,24 +262,29 @@ instance.setParticleColor('hsl(149, 100%, 50%)')
360
262
 
361
263
  #### Changing the particle count
362
264
 
363
- After updating the following options, the number of particles is not automatically adjusted:
265
+ After updating the following options, the number of particles is **not automatically adjusted**:
364
266
 
365
- - option.particles.ppm
366
- - option.particles.max
267
+ - `particles.ppm`
268
+ - `particles.max`
367
269
 
368
270
  ```js
369
271
  // Note: the backing field is called `option` not `options`!
370
272
  instance.option.particles.ppm = 100
371
273
  instance.option.particles.max = 300
274
+ ```
275
+
276
+ The changes are only applied when one of the following methods is called.
372
277
 
373
- // Apply the changes using one of these methods:
278
+ ```js
374
279
  instance.newParticles() // Remove all particles and create the correct amount of new ones
375
280
  instance.matchParticleCount() // Add or remove some particles to match the count
376
281
  ```
377
282
 
378
283
  #### Modifying object properties
379
284
 
380
- **All** other options can be updated by modifying the `option` internal field properties, with changes taking immediate effect.
285
+ **All** other options can be updated by only modifying the `option` internal field properties, with changes taking effect immediately.
286
+
287
+ > The new option values are not validated. Assigning invalid values will lead to unexpected behavior. It is therefore recommended to use the [options setter](#updating-entire-options-object).
381
288
 
382
289
  ```js
383
290
  // Note: the backing field is called `option` not `options`!
@@ -391,11 +298,12 @@ instance.option.gravity.repulsive = 1
391
298
  To reinitialize all options, pass a new options object to the `options` setter.
392
299
 
393
300
  ```js
394
- const options = { ... }
395
- instance.options = options
301
+ instance.options = { ... }
396
302
  ```
397
303
 
398
- ## One pager example
304
+ ---
305
+
306
+ ## One Pager Example
399
307
 
400
308
  ```html
401
309
  <!DOCTYPE html>
@@ -423,7 +331,7 @@ instance.options = options
423
331
  const options = {
424
332
  background: 'hsl(125, 42%, 35%)',
425
333
  mouse: {
426
- interactionType: CanvasParticles.interactionType.MOVE, // MOVE === 2
334
+ interactionType: CanvasParticles.interactionType.MOVE, // = 2
427
335
  },
428
336
  particles: {
429
337
  color: 'rgba(150, 255, 105, 0.95)',
package/dist/index.cjs CHANGED
@@ -16,12 +16,14 @@ function Mulberry32(seed) {
16
16
  },
17
17
  };
18
18
  }
19
- // Mulberry32 is ±388% faster than Math.random()
19
+ // Mulberry32 is ±392% faster than Math.random()
20
20
  // Benchmark: https://jsbm.dev/muLCWR9RJCbmy
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.1.5";
24
+ static version = "4.2.1";
25
+ static MAX_DT = 1000 / 50; // milliseconds between updates @ 50 FPS
26
+ static BASE_DT = 1000 / 60; // milliseconds between updates @ 60 FPS
25
27
  /** Defines mouse interaction types with the particles */
26
28
  static interactionType = Object.freeze({
27
29
  NONE: 0, // No mouse interaction
@@ -57,10 +59,8 @@ class CanvasParticles {
57
59
  canvas.instance.resizeCanvas();
58
60
  }
59
61
  });
60
- static MAX_DT = 1000 / 30; // milliseconds between updates @ 30 FPS
61
- static BASE_DT = 1000 / 60; // milliseconds between updates @ 60 FPS
62
62
  /** Helper functions for options parsing */
63
- static defaultIfNaN = (value, defaultValue) => (isNaN(+value) ? defaultValue : +value);
63
+ static defaultIfNaN = (value, defaultValue) => isNaN(+value) ? defaultValue : +value;
64
64
  static parseNumericOption = (name, value, defaultValue, clamp) => {
65
65
  if (value == undefined)
66
66
  return defaultValue;
@@ -68,7 +68,7 @@ class CanvasParticles {
68
68
  if (isFinite(min) && value < min) {
69
69
  console.warn(new RangeError(`option.${name} was clamped to ${min} as ${value} is too low`));
70
70
  }
71
- if (isFinite(max) && value > max) {
71
+ else if (isFinite(max) && value > max) {
72
72
  console.warn(new RangeError(`option.${name} was clamped to ${max} as ${value} is too high`));
73
73
  }
74
74
  return CanvasParticles.defaultIfNaN(Math.min(Math.max(value ?? defaultValue, min), max), defaultValue);
@@ -237,11 +237,12 @@ class CanvasParticles {
237
237
  return;
238
238
  const len = this.particleCount;
239
239
  const particles = this.particles;
240
- const gravRepulsiveMult = this.option.particles.connectDist * this.option.gravity.repulsive;
241
- const gravPullingMult = this.option.particles.connectDist * this.option.gravity.pulling;
242
- const maxRepulsiveDist = this.option.particles.connectDist / 2;
243
- const maxRepulsiveDistSq = maxRepulsiveDist ** 2;
244
- const maxGrav = this.option.particles.connectDist * 0.1;
240
+ const connectDist = this.option.particles.connectDist;
241
+ const gravRepulsiveMult = connectDist * this.option.gravity.repulsive * step;
242
+ const gravPullingMult = connectDist * this.option.gravity.pulling * step;
243
+ const maxRepulsiveDist = connectDist / 2;
244
+ const maxRepulsiveDistSq = maxRepulsiveDist * maxRepulsiveDist;
245
+ const eps = (connectDist * connectDist) / 256;
245
246
  for (let i = 0; i < len; i++) {
246
247
  const particleA = particles[i];
247
248
  for (let j = i + 1; j < len; j++) {
@@ -250,17 +251,17 @@ class CanvasParticles {
250
251
  const distX = particleA.posX - particleB.posX;
251
252
  const distY = particleA.posY - particleB.posY;
252
253
  const distSq = distX * distX + distY * distY;
254
+ if (distSq >= maxRepulsiveDistSq && !isPullingEnabled)
255
+ continue;
253
256
  let angle;
254
257
  let grav;
255
258
  let gravMult;
256
- if (distSq >= maxRepulsiveDistSq && !isPullingEnabled)
257
- continue;
258
- angle = Math.atan2(particleB.posY - particleA.posY, particleB.posX - particleA.posX);
259
- grav = (1 / Math.sqrt(distSq)) ** 1.8;
259
+ angle = Math.atan2(-distY, -distX);
260
+ grav = 1 / (distSq + eps);
260
261
  const angleX = Math.cos(angle);
261
262
  const angleY = Math.sin(angle);
262
263
  if (distSq < maxRepulsiveDistSq) {
263
- gravMult = Math.min(maxGrav, grav * gravRepulsiveMult);
264
+ gravMult = grav * gravRepulsiveMult;
264
265
  const gravX = angleX * gravMult;
265
266
  const gravY = angleY * gravMult;
266
267
  particleA.velX -= gravX;
@@ -270,7 +271,7 @@ class CanvasParticles {
270
271
  }
271
272
  if (!isPullingEnabled)
272
273
  continue;
273
- gravMult = Math.min(maxGrav, grav * gravPullingMult);
274
+ gravMult = grav * gravPullingMult;
274
275
  const gravX = angleX * gravMult;
275
276
  const gravY = angleY * gravMult;
276
277
  particleA.velX += gravX;
@@ -290,22 +291,23 @@ class CanvasParticles {
290
291
  const offY = this.offY;
291
292
  const mouseX = this.mouseX;
292
293
  const mouseY = this.mouseY;
293
- const rotationSpeed = this.option.particles.rotationSpeed;
294
+ const rotationSpeed = this.option.particles.rotationSpeed * step;
294
295
  const friction = this.option.gravity.friction;
295
296
  const mouseConnectDist = this.option.mouse.connectDist;
296
297
  const mouseDistRatio = this.option.mouse.distRatio;
297
298
  const isMouseInteractionTypeNone = this.option.mouse.interactionType === CanvasParticles.interactionType.NONE;
298
299
  const isMouseInteractionTypeMove = this.option.mouse.interactionType === CanvasParticles.interactionType.MOVE;
300
+ const easing = 1 - Math.pow(1 - 1 / 4, step);
299
301
  for (let i = 0; i < len; i++) {
300
302
  const particle = particles[i];
301
- particle.dir += 2 * (Math.random() - 0.5) * rotationSpeed;
303
+ particle.dir += 2 * (Math.random() - 0.5) * rotationSpeed * step;
302
304
  particle.dir %= TWO_PI;
303
305
  // Constant velocity
304
306
  const movX = Math.sin(particle.dir) * particle.speed;
305
307
  const movY = Math.cos(particle.dir) * particle.speed;
306
308
  // Apply velocities
307
- particle.posX += movX + particle.velX;
308
- particle.posY += movY + particle.velY;
309
+ particle.posX += (movX + particle.velX) * step;
310
+ particle.posY += (movY + particle.velY) * step;
309
311
  // Wrap particles around the canvas
310
312
  particle.posX %= width;
311
313
  if (particle.posX < 0)
@@ -314,8 +316,8 @@ class CanvasParticles {
314
316
  if (particle.posY < 0)
315
317
  particle.posY += height;
316
318
  // Slightly decrease dynamic velocity
317
- particle.velX *= friction;
318
- particle.velY *= friction;
319
+ particle.velX *= Math.pow(friction, step);
320
+ particle.velY *= Math.pow(friction, step);
319
321
  // Distance from mouse
320
322
  const distX = particle.posX + offX - mouseX;
321
323
  const distY = particle.posY + offY - mouseY;
@@ -323,12 +325,12 @@ class CanvasParticles {
323
325
  if (!isMouseInteractionTypeNone) {
324
326
  const distRatio = mouseConnectDist / Math.hypot(distX, distY);
325
327
  if (mouseDistRatio < distRatio) {
326
- particle.offX += (distRatio * distX - distX - particle.offX) / 4;
327
- particle.offY += (distRatio * distY - distY - particle.offY) / 4;
328
+ particle.offX += (distRatio * distX - distX - particle.offX) * easing;
329
+ particle.offY += (distRatio * distY - distY - particle.offY) * easing;
328
330
  }
329
331
  else {
330
- particle.offX -= particle.offX / 4;
331
- particle.offY -= particle.offY / 4;
332
+ particle.offX -= particle.offX * easing;
333
+ particle.offY -= particle.offY * easing;
332
334
  }
333
335
  }
334
336
  // Visually displace the particles
@@ -403,9 +405,9 @@ class CanvasParticles {
403
405
  const particles = this.particles;
404
406
  const ctx = this.ctx;
405
407
  const maxDist = this.option.particles.connectDist;
406
- const maxDistSq = maxDist ** 2;
408
+ const maxDistSq = maxDist * maxDist;
407
409
  const halfMaxDist = maxDist / 2;
408
- const halfMaxDistSq = halfMaxDist ** 2;
410
+ const halfMaxDistSq = halfMaxDist * halfMaxDist;
409
411
  const drawAll = maxDist >= Math.min(this.canvas.width, this.canvas.height);
410
412
  const maxWorkPerParticle = maxDistSq * this.option.particles.maxWork;
411
413
  const alpha = this.color.alpha;
@@ -419,7 +421,7 @@ class CanvasParticles {
419
421
  // Code in this scope runs O(n^2) times per frame!
420
422
  const particleB = particles[j];
421
423
  // Don't draw the line if it wouldn't be visible
422
- if (!(drawAll || this.#isLineVisible(particleA, particleB)))
424
+ if (!drawAll && !this.#isLineVisible(particleA, particleB))
423
425
  continue;
424
426
  const distX = particleA.x - particleB.x;
425
427
  const distY = particleA.y - particleB.y;
@@ -464,7 +466,8 @@ class CanvasParticles {
464
466
  this.ctx.strokeStyle = this.color.hex;
465
467
  this.ctx.lineWidth = 1;
466
468
  this.#renderParticles();
467
- this.#renderConnections();
469
+ if (this.options.particles.drawLines)
470
+ this.#renderConnections();
468
471
  }
469
472
  /** @private Main animation loop that updates and renders the particles */
470
473
  #animation() {
@@ -478,7 +481,7 @@ class CanvasParticles {
478
481
  // - step = 1 → exactly one baseline update (dt === BASE_DT)
479
482
  // - step > 1 → more time passed (lower FPS), advance further
480
483
  // - step < 1 → less time passed (higher FPS), advance less
481
- const step = CanvasParticles.BASE_DT / dt;
484
+ const step = dt / CanvasParticles.BASE_DT;
482
485
  this.#updateGravity(step);
483
486
  this.#updateParticles(step);
484
487
  this.#render();
@@ -527,20 +530,21 @@ class CanvasParticles {
527
530
  stopOnLeave: !!(options.animation?.stopOnLeave ?? true),
528
531
  },
529
532
  mouse: {
530
- interactionType: pno('mouse.interactionType', options.mouse?.interactionType, 1),
533
+ interactionType: ~~pno('mouse.interactionType', options.mouse?.interactionType, CanvasParticles.interactionType.MOVE, { min: 0, max: 2 }),
531
534
  connectDistMult: pno('mouse.connectDistMult', options.mouse?.connectDistMult, 2 / 3),
532
535
  connectDist: 1 /* post processed */,
533
536
  distRatio: pno('mouse.distRatio', options.mouse?.distRatio, 2 / 3),
534
537
  },
535
538
  particles: {
536
539
  regenerateOnResize: !!options.particles?.regenerateOnResize,
540
+ drawLines: !!(options.particles?.drawLines ?? true),
537
541
  color: options.particles?.color ?? 'black',
538
- ppm: pno('particles.ppm', options.particles?.ppm, 100),
539
- max: pno('particles.max', options.particles?.max, Infinity),
540
- maxWork: pno('particles.maxWork', options.particles?.maxWork, Infinity, { min: 0 }),
541
- connectDist: pno('particles.connectDistance', options.particles?.connectDistance, 150, { min: 1 }),
542
+ ppm: ~~pno('particles.ppm', options.particles?.ppm, 100),
543
+ max: Math.round(pno('particles.max', options.particles?.max, Infinity)),
544
+ maxWork: Math.round(pno('particles.maxWork', options.particles?.maxWork, Infinity, { min: 0 })),
545
+ connectDist: ~~pno('particles.connectDistance', options.particles?.connectDistance, 150, { min: 1 }),
542
546
  relSpeed: pno('particles.relSpeed', options.particles?.relSpeed, 1, { min: 0 }),
543
- relSize: pno('particles.relSize', options.particles?.relSize, 1, { min: 1 }),
547
+ relSize: pno('particles.relSize', options.particles?.relSize, 1, { min: 0 }),
544
548
  rotationSpeed: pno('particles.rotationSpeed', options.particles?.rotationSpeed, 2, { min: 0 }) / 100,
545
549
  },
546
550
  gravity: {
package/dist/index.d.ts CHANGED
@@ -3,8 +3,10 @@ import type { CanvasParticlesOptions, CanvasParticlesOptionsInput } from './type
3
3
  export default class CanvasParticles {
4
4
  #private;
5
5
  static readonly version: string;
6
+ private static readonly MAX_DT;
7
+ private static readonly BASE_DT;
6
8
  /** Defines mouse interaction types with the particles */
7
- static interactionType: Readonly<{
9
+ static readonly interactionType: Readonly<{
8
10
  NONE: 0;
9
11
  SHIFT: 1;
10
12
  MOVE: 2;
@@ -12,11 +14,9 @@ export default class CanvasParticles {
12
14
  /** Observes canvas elements entering or leaving the viewport to start/stop animation */
13
15
  static readonly canvasIntersectionObserver: IntersectionObserver;
14
16
  static readonly canvasResizeObserver: ResizeObserver;
15
- private static readonly MAX_DT;
16
- private static readonly BASE_DT;
17
17
  /** Helper functions for options parsing */
18
- private static defaultIfNaN;
19
- private static parseNumericOption;
18
+ private static readonly defaultIfNaN;
19
+ private static readonly parseNumericOption;
20
20
  canvas: CanvasParticlesCanvas;
21
21
  private ctx;
22
22
  enableAnimating: boolean;
package/dist/index.mjs CHANGED
@@ -14,12 +14,14 @@ function Mulberry32(seed) {
14
14
  },
15
15
  };
16
16
  }
17
- // Mulberry32 is ±388% faster than Math.random()
17
+ // Mulberry32 is ±392% faster than Math.random()
18
18
  // Benchmark: https://jsbm.dev/muLCWR9RJCbmy
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.1.5";
22
+ static version = "4.2.1";
23
+ static MAX_DT = 1000 / 50; // milliseconds between updates @ 50 FPS
24
+ static BASE_DT = 1000 / 60; // milliseconds between updates @ 60 FPS
23
25
  /** Defines mouse interaction types with the particles */
24
26
  static interactionType = Object.freeze({
25
27
  NONE: 0, // No mouse interaction
@@ -55,10 +57,8 @@ class CanvasParticles {
55
57
  canvas.instance.resizeCanvas();
56
58
  }
57
59
  });
58
- static MAX_DT = 1000 / 30; // milliseconds between updates @ 30 FPS
59
- static BASE_DT = 1000 / 60; // milliseconds between updates @ 60 FPS
60
60
  /** Helper functions for options parsing */
61
- static defaultIfNaN = (value, defaultValue) => (isNaN(+value) ? defaultValue : +value);
61
+ static defaultIfNaN = (value, defaultValue) => isNaN(+value) ? defaultValue : +value;
62
62
  static parseNumericOption = (name, value, defaultValue, clamp) => {
63
63
  if (value == undefined)
64
64
  return defaultValue;
@@ -66,7 +66,7 @@ class CanvasParticles {
66
66
  if (isFinite(min) && value < min) {
67
67
  console.warn(new RangeError(`option.${name} was clamped to ${min} as ${value} is too low`));
68
68
  }
69
- if (isFinite(max) && value > max) {
69
+ else if (isFinite(max) && value > max) {
70
70
  console.warn(new RangeError(`option.${name} was clamped to ${max} as ${value} is too high`));
71
71
  }
72
72
  return CanvasParticles.defaultIfNaN(Math.min(Math.max(value ?? defaultValue, min), max), defaultValue);
@@ -235,11 +235,12 @@ class CanvasParticles {
235
235
  return;
236
236
  const len = this.particleCount;
237
237
  const particles = this.particles;
238
- const gravRepulsiveMult = this.option.particles.connectDist * this.option.gravity.repulsive;
239
- const gravPullingMult = this.option.particles.connectDist * this.option.gravity.pulling;
240
- const maxRepulsiveDist = this.option.particles.connectDist / 2;
241
- const maxRepulsiveDistSq = maxRepulsiveDist ** 2;
242
- const maxGrav = this.option.particles.connectDist * 0.1;
238
+ const connectDist = this.option.particles.connectDist;
239
+ const gravRepulsiveMult = connectDist * this.option.gravity.repulsive * step;
240
+ const gravPullingMult = connectDist * this.option.gravity.pulling * step;
241
+ const maxRepulsiveDist = connectDist / 2;
242
+ const maxRepulsiveDistSq = maxRepulsiveDist * maxRepulsiveDist;
243
+ const eps = (connectDist * connectDist) / 256;
243
244
  for (let i = 0; i < len; i++) {
244
245
  const particleA = particles[i];
245
246
  for (let j = i + 1; j < len; j++) {
@@ -248,17 +249,17 @@ class CanvasParticles {
248
249
  const distX = particleA.posX - particleB.posX;
249
250
  const distY = particleA.posY - particleB.posY;
250
251
  const distSq = distX * distX + distY * distY;
252
+ if (distSq >= maxRepulsiveDistSq && !isPullingEnabled)
253
+ continue;
251
254
  let angle;
252
255
  let grav;
253
256
  let gravMult;
254
- if (distSq >= maxRepulsiveDistSq && !isPullingEnabled)
255
- continue;
256
- angle = Math.atan2(particleB.posY - particleA.posY, particleB.posX - particleA.posX);
257
- grav = (1 / Math.sqrt(distSq)) ** 1.8;
257
+ angle = Math.atan2(-distY, -distX);
258
+ grav = 1 / (distSq + eps);
258
259
  const angleX = Math.cos(angle);
259
260
  const angleY = Math.sin(angle);
260
261
  if (distSq < maxRepulsiveDistSq) {
261
- gravMult = Math.min(maxGrav, grav * gravRepulsiveMult);
262
+ gravMult = grav * gravRepulsiveMult;
262
263
  const gravX = angleX * gravMult;
263
264
  const gravY = angleY * gravMult;
264
265
  particleA.velX -= gravX;
@@ -268,7 +269,7 @@ class CanvasParticles {
268
269
  }
269
270
  if (!isPullingEnabled)
270
271
  continue;
271
- gravMult = Math.min(maxGrav, grav * gravPullingMult);
272
+ gravMult = grav * gravPullingMult;
272
273
  const gravX = angleX * gravMult;
273
274
  const gravY = angleY * gravMult;
274
275
  particleA.velX += gravX;
@@ -288,22 +289,23 @@ class CanvasParticles {
288
289
  const offY = this.offY;
289
290
  const mouseX = this.mouseX;
290
291
  const mouseY = this.mouseY;
291
- const rotationSpeed = this.option.particles.rotationSpeed;
292
+ const rotationSpeed = this.option.particles.rotationSpeed * step;
292
293
  const friction = this.option.gravity.friction;
293
294
  const mouseConnectDist = this.option.mouse.connectDist;
294
295
  const mouseDistRatio = this.option.mouse.distRatio;
295
296
  const isMouseInteractionTypeNone = this.option.mouse.interactionType === CanvasParticles.interactionType.NONE;
296
297
  const isMouseInteractionTypeMove = this.option.mouse.interactionType === CanvasParticles.interactionType.MOVE;
298
+ const easing = 1 - Math.pow(1 - 1 / 4, step);
297
299
  for (let i = 0; i < len; i++) {
298
300
  const particle = particles[i];
299
- particle.dir += 2 * (Math.random() - 0.5) * rotationSpeed;
301
+ particle.dir += 2 * (Math.random() - 0.5) * rotationSpeed * step;
300
302
  particle.dir %= TWO_PI;
301
303
  // Constant velocity
302
304
  const movX = Math.sin(particle.dir) * particle.speed;
303
305
  const movY = Math.cos(particle.dir) * particle.speed;
304
306
  // Apply velocities
305
- particle.posX += movX + particle.velX;
306
- particle.posY += movY + particle.velY;
307
+ particle.posX += (movX + particle.velX) * step;
308
+ particle.posY += (movY + particle.velY) * step;
307
309
  // Wrap particles around the canvas
308
310
  particle.posX %= width;
309
311
  if (particle.posX < 0)
@@ -312,8 +314,8 @@ class CanvasParticles {
312
314
  if (particle.posY < 0)
313
315
  particle.posY += height;
314
316
  // Slightly decrease dynamic velocity
315
- particle.velX *= friction;
316
- particle.velY *= friction;
317
+ particle.velX *= Math.pow(friction, step);
318
+ particle.velY *= Math.pow(friction, step);
317
319
  // Distance from mouse
318
320
  const distX = particle.posX + offX - mouseX;
319
321
  const distY = particle.posY + offY - mouseY;
@@ -321,12 +323,12 @@ class CanvasParticles {
321
323
  if (!isMouseInteractionTypeNone) {
322
324
  const distRatio = mouseConnectDist / Math.hypot(distX, distY);
323
325
  if (mouseDistRatio < distRatio) {
324
- particle.offX += (distRatio * distX - distX - particle.offX) / 4;
325
- particle.offY += (distRatio * distY - distY - particle.offY) / 4;
326
+ particle.offX += (distRatio * distX - distX - particle.offX) * easing;
327
+ particle.offY += (distRatio * distY - distY - particle.offY) * easing;
326
328
  }
327
329
  else {
328
- particle.offX -= particle.offX / 4;
329
- particle.offY -= particle.offY / 4;
330
+ particle.offX -= particle.offX * easing;
331
+ particle.offY -= particle.offY * easing;
330
332
  }
331
333
  }
332
334
  // Visually displace the particles
@@ -401,9 +403,9 @@ class CanvasParticles {
401
403
  const particles = this.particles;
402
404
  const ctx = this.ctx;
403
405
  const maxDist = this.option.particles.connectDist;
404
- const maxDistSq = maxDist ** 2;
406
+ const maxDistSq = maxDist * maxDist;
405
407
  const halfMaxDist = maxDist / 2;
406
- const halfMaxDistSq = halfMaxDist ** 2;
408
+ const halfMaxDistSq = halfMaxDist * halfMaxDist;
407
409
  const drawAll = maxDist >= Math.min(this.canvas.width, this.canvas.height);
408
410
  const maxWorkPerParticle = maxDistSq * this.option.particles.maxWork;
409
411
  const alpha = this.color.alpha;
@@ -417,7 +419,7 @@ class CanvasParticles {
417
419
  // Code in this scope runs O(n^2) times per frame!
418
420
  const particleB = particles[j];
419
421
  // Don't draw the line if it wouldn't be visible
420
- if (!(drawAll || this.#isLineVisible(particleA, particleB)))
422
+ if (!drawAll && !this.#isLineVisible(particleA, particleB))
421
423
  continue;
422
424
  const distX = particleA.x - particleB.x;
423
425
  const distY = particleA.y - particleB.y;
@@ -462,7 +464,8 @@ class CanvasParticles {
462
464
  this.ctx.strokeStyle = this.color.hex;
463
465
  this.ctx.lineWidth = 1;
464
466
  this.#renderParticles();
465
- this.#renderConnections();
467
+ if (this.options.particles.drawLines)
468
+ this.#renderConnections();
466
469
  }
467
470
  /** @private Main animation loop that updates and renders the particles */
468
471
  #animation() {
@@ -476,7 +479,7 @@ class CanvasParticles {
476
479
  // - step = 1 → exactly one baseline update (dt === BASE_DT)
477
480
  // - step > 1 → more time passed (lower FPS), advance further
478
481
  // - step < 1 → less time passed (higher FPS), advance less
479
- const step = CanvasParticles.BASE_DT / dt;
482
+ const step = dt / CanvasParticles.BASE_DT;
480
483
  this.#updateGravity(step);
481
484
  this.#updateParticles(step);
482
485
  this.#render();
@@ -525,20 +528,21 @@ class CanvasParticles {
525
528
  stopOnLeave: !!(options.animation?.stopOnLeave ?? true),
526
529
  },
527
530
  mouse: {
528
- interactionType: pno('mouse.interactionType', options.mouse?.interactionType, 1),
531
+ interactionType: ~~pno('mouse.interactionType', options.mouse?.interactionType, CanvasParticles.interactionType.MOVE, { min: 0, max: 2 }),
529
532
  connectDistMult: pno('mouse.connectDistMult', options.mouse?.connectDistMult, 2 / 3),
530
533
  connectDist: 1 /* post processed */,
531
534
  distRatio: pno('mouse.distRatio', options.mouse?.distRatio, 2 / 3),
532
535
  },
533
536
  particles: {
534
537
  regenerateOnResize: !!options.particles?.regenerateOnResize,
538
+ drawLines: !!(options.particles?.drawLines ?? true),
535
539
  color: options.particles?.color ?? 'black',
536
- ppm: pno('particles.ppm', options.particles?.ppm, 100),
537
- max: pno('particles.max', options.particles?.max, Infinity),
538
- maxWork: pno('particles.maxWork', options.particles?.maxWork, Infinity, { min: 0 }),
539
- connectDist: pno('particles.connectDistance', options.particles?.connectDistance, 150, { min: 1 }),
540
+ ppm: ~~pno('particles.ppm', options.particles?.ppm, 100),
541
+ max: Math.round(pno('particles.max', options.particles?.max, Infinity)),
542
+ maxWork: Math.round(pno('particles.maxWork', options.particles?.maxWork, Infinity, { min: 0 })),
543
+ connectDist: ~~pno('particles.connectDistance', options.particles?.connectDistance, 150, { min: 1 }),
540
544
  relSpeed: pno('particles.relSpeed', options.particles?.relSpeed, 1, { min: 0 }),
541
- relSize: pno('particles.relSize', options.particles?.relSize, 1, { min: 1 }),
545
+ relSize: pno('particles.relSize', options.particles?.relSize, 1, { min: 0 }),
542
546
  rotationSpeed: pno('particles.rotationSpeed', options.particles?.rotationSpeed, 2, { min: 0 }) / 100,
543
547
  },
544
548
  gravity: {
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.1.5";static interactionType=Object.freeze({NONE:0,SHIFT:1,MOVE: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})}});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 MAX_DT=1e3/30;static BASE_DT=1e3/60;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=[];particleCount=0;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,this.option.particles.regenerateOnResize||0===this.particles.length?this.newParticles():this.matchParticleCount({updateBounds:!0}),this.isAnimating&&this.#t()}#i(){const t=this.option.particles.ppm*this.width*this.height/1e6|0;if(this.particleCount=Math.min(this.option.particles.max,t),!isFinite(this.particleCount))throw new RangeError("particleCount must be finite")}newParticles(){this.#i(),this.particles=[];for(let t=0;t<this.particleCount;t++)this.createParticle()}matchParticleCount({updateBounds:t=!1}={}){for(this.#i(),this.particles=this.particles.slice(0,this.particleCount),t&&this.particles.forEach(t=>this.#e(t));this.particleCount>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+i()**5*2)*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}}#s(t){const i=this.option.gravity.repulsive>0,e=this.option.gravity.pulling>0;if(!i&&!e)return;const s=this.particleCount,n=this.particles,o=this.option.particles.connectDist*this.option.gravity.repulsive,a=this.option.particles.connectDist*this.option.gravity.pulling,r=(this.option.particles.connectDist/2)**2,c=.1*this.option.particles.connectDist;for(let t=0;t<s;t++){const i=n[t];for(let h=t+1;h<s;h++){const t=n[h],s=i.posX-t.posX,l=i.posY-t.posY,p=s*s+l*l;let u,d,f;if(p>=r&&!e)continue;u=Math.atan2(t.posY-i.posY,t.posX-i.posX),d=(1/Math.sqrt(p))**1.8;const m=Math.cos(u),v=Math.sin(u);if(p<r){f=Math.min(c,d*o);const e=m*f,s=v*f;i.velX-=e,i.velY-=s,t.velX+=e,t.velY+=s}if(!e)continue;f=Math.min(c,d*a);const g=m*f,y=v*f;i.velX+=g,i.velY+=y,t.velX-=g,t.velY-=y}}}#n(i){const s=this.particleCount,n=this.particles,o=this.width,a=this.height,r=this.offX,c=this.offY,h=this.mouseX,l=this.mouseY,p=this.option.particles.rotationSpeed,u=this.option.gravity.friction,d=this.option.mouse.connectDist,f=this.option.mouse.distRatio,m=this.option.mouse.interactionType===e.interactionType.NONE,v=this.option.mouse.interactionType===e.interactionType.MOVE;for(let i=0;i<s;i++){const e=n[i];e.dir+=2*(Math.random()-.5)*p,e.dir%=t;const s=Math.sin(e.dir)*e.speed,g=Math.cos(e.dir)*e.speed;e.posX+=s+e.velX,e.posY+=g+e.velY,e.posX%=o,e.posX<0&&(e.posX+=o),e.posY%=a,e.posY<0&&(e.posY+=a),e.velX*=u,e.velY*=u;const y=e.posX+r-h,x=e.posY+c-l;if(!m){const t=d/Math.hypot(y,x);f<t?(e.offX+=(t*y-y-e.offX)/4,e.offY+=(t*x-x-e.offY)/4):(e.offX-=e.offX/4,e.offY-=e.offY/4)}e.x=e.posX+e.offX,e.y=e.posY+e.offY,v&&(e.posX=e.x,e.posY=e.y),e.x+=r,e.y+=c,this.#o(e),e.isVisible=1===e.gridPos.x&&1===e.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.particleCount,e=this.particles,s=this.ctx;for(let n=0;n<i;n++){const i=e[n];i.isVisible&&(i.size>1?(s.beginPath(),s.arc(i.x,i.y,i.size,0,t),s.fill(),s.closePath()):s.fillRect(i.x-i.size,i.y-i.size,2*i.size,2*i.size))}}#c(){const t=this.particleCount,i=this.particles,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<t;s++){const p=i[s];let u=0;for(let d=s+1;d<t;d++){const t=i[d];if(!a&&!this.#a(p,t))continue;const s=p.x-t.x,f=p.y-t.y,m=s*s+f*f;if(!(m>n)&&(m>o?(e.globalAlpha=h/Math.sqrt(m)-c,e.beginPath(),e.moveTo(p.x,p.y),e.lineTo(t.x,t.y),e.stroke()):l.push([p.x,p.y,t.x,t.y]),(u+=m)>=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.#c()}#h(){if(!this.isAnimating)return;requestAnimationFrame(()=>this.#h());const t=performance.now(),i=Math.min(t-this.lastAnimationFrame,e.MAX_DT),s=e.BASE_DT/i;this.#s(s),this.#n(s),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,1),connectDistMult:i("mouse.connectDistMult",t.mouse?.connectDistMult,2/3),connectDist:1,distRatio:i("mouse.distRatio",t.mouse?.distRatio,2/3)},particles:{regenerateOnResize:!!t.particles?.regenerateOnResize,color:t.particles?.color??"black",ppm:i("particles.ppm",t.particles?.ppm,100),max:i("particles.max",t.particles?.max,1/0),maxWork: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:1}),rotationSpeed:i("particles.rotationSpeed",t.particles?.rotationSpeed,2,{min:0})/100},gravity:{repulsive:i("gravity.repulsive",t.gravity?.repulsive,0),pulling:i("gravity.pulling",t.gravity?.pulling,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){this.option.mouse.connectDist=this.option.particles.connectDist*(isNaN(t)?2/3:t)}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.2.1";static MAX_DT=20;static BASE_DT=1e3/60;static interactionType=Object.freeze({NONE:0,SHIFT:1,MOVE: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})}});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=[];particleCount=0;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,this.option.particles.regenerateOnResize||0===this.particles.length?this.newParticles():this.matchParticleCount({updateBounds:!0}),this.isAnimating&&this.#t()}#i(){const t=this.option.particles.ppm*this.width*this.height/1e6|0;if(this.particleCount=Math.min(this.option.particles.max,t),!isFinite(this.particleCount))throw new RangeError("particleCount must be finite")}newParticles(){this.#i(),this.particles=[];for(let t=0;t<this.particleCount;t++)this.createParticle()}matchParticleCount({updateBounds:t=!1}={}){for(this.#i(),this.particles=this.particles.slice(0,this.particleCount),t&&this.particles.forEach(t=>this.#e(t));this.particleCount>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+i()**5*2)*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}}#s(t){const i=this.option.gravity.repulsive>0,e=this.option.gravity.pulling>0;if(!i&&!e)return;const s=this.particleCount,n=this.particles,o=this.option.particles.connectDist,a=o*this.option.gravity.repulsive*t,r=o*this.option.gravity.pulling*t,c=o/2,h=c*c,l=o*o/256;for(let t=0;t<s;t++){const i=n[t];for(let o=t+1;o<s;o++){const t=n[o],s=i.posX-t.posX,c=i.posY-t.posY,p=s*s+c*c;if(p>=h&&!e)continue;let u,d,f;u=Math.atan2(-c,-s),d=1/(p+l);const m=Math.cos(u),v=Math.sin(u);if(p<h){f=d*a;const e=m*f,s=v*f;i.velX-=e,i.velY-=s,t.velX+=e,t.velY+=s}if(!e)continue;f=d*r;const g=m*f,y=v*f;i.velX+=g,i.velY+=y,t.velX-=g,t.velY-=y}}}#n(i){const s=this.particleCount,n=this.particles,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,f=this.option.mouse.distRatio,m=this.option.mouse.interactionType===e.interactionType.NONE,v=this.option.mouse.interactionType===e.interactionType.MOVE,g=1-Math.pow(.75,i);for(let e=0;e<s;e++){const s=n[e];s.dir+=2*(Math.random()-.5)*p*i,s.dir%=t;const y=Math.sin(s.dir)*s.speed,x=Math.cos(s.dir)*s.speed;s.posX+=(y+s.velX)*i,s.posY+=(x+s.velY)*i,s.posX%=o,s.posX<0&&(s.posX+=o),s.posY%=a,s.posY<0&&(s.posY+=a),s.velX*=Math.pow(u,i),s.velY*=Math.pow(u,i);const b=s.posX+r-h,w=s.posY+c-l;if(!m){const t=d/Math.hypot(b,w);f<t?(s.offX+=(t*b-b-s.offX)*g,s.offY+=(t*w-w-s.offY)*g):(s.offX-=s.offX*g,s.offY-=s.offY*g)}s.x=s.posX+s.offX,s.y=s.posY+s.offY,v&&(s.posX=s.x,s.posY=s.y),s.x+=r,s.y+=c,this.#o(s),s.isVisible=1===s.gridPos.x&&1===s.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.particleCount,e=this.particles,s=this.ctx;for(let n=0;n<i;n++){const i=e[n];i.isVisible&&(i.size>1?(s.beginPath(),s.arc(i.x,i.y,i.size,0,t),s.fill(),s.closePath()):s.fillRect(i.x-i.size,i.y-i.size,2*i.size,2*i.size))}}#c(){const t=this.particleCount,i=this.particles,e=this.ctx,s=this.option.particles.connectDist,n=s*s,o=s/2,a=o*o,r=s>=Math.min(this.canvas.width,this.canvas.height),c=n*this.option.particles.maxWork,h=this.color.alpha,l=this.color.alpha*s,p=[];for(let s=0;s<t;s++){const o=i[s];let u=0;for(let d=s+1;d<t;d++){const t=i[d];if(!r&&!this.#a(o,t))continue;const s=o.x-t.x,f=o.y-t.y,m=s*s+f*f;if(!(m>n)&&(m>a?(e.globalAlpha=l/Math.sqrt(m)-h,e.beginPath(),e.moveTo(o.x,o.y),e.lineTo(t.x,t.y),e.stroke()):p.push([o.x,o.y,t.x,t.y]),(u+=m)>=c))break}}if(p.length){e.globalAlpha=h,e.beginPath();for(let t=0;t<p.length;t++){const i=p[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),connectDist:1,distRatio:i("mouse.distRatio",t.mouse?.distRatio,2/3)},particles:{regenerateOnResize:!!t.particles?.regenerateOnResize,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)),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),pulling:i("gravity.pulling",t.gravity?.pulling,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){this.option.mouse.connectDist=this.option.particles.connectDist*(isNaN(t)?2/3:t)}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});
@@ -12,6 +12,7 @@ export interface CanvasParticlesOptions {
12
12
  };
13
13
  particles: {
14
14
  regenerateOnResize: boolean;
15
+ drawLines: boolean;
15
16
  color: string | CanvasGradient | CanvasPattern;
16
17
  ppm: number;
17
18
  max: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canvasparticles-js",
3
- "version": "4.1.5",
3
+ "version": "4.2.1",
4
4
  "description": "In an HTML canvas, a bunch of interactive particles connected with lines when they approach each other.",
5
5
  "author": "Khoeckman",
6
6
  "license": "MIT",
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Copyright (c) 2022–2025 Kyle Hoeckman, MIT License
2
2
  // https://github.com/Khoeckman/canvasparticles-js/blob/main/LICENSE
3
3
 
4
- import type { CanvasParticlesCanvas, Particle, ParticleGridPos, ContextColor, LineSegment } from './types'
4
+ import type { CanvasParticlesCanvas, Particle, ContextColor, LineSegment } from './types'
5
5
  import type { CanvasParticlesOptions, CanvasParticlesOptionsInput } from './types/options'
6
6
 
7
7
  const TWO_PI = 2 * Math.PI
@@ -21,7 +21,7 @@ function Mulberry32(seed: number) {
21
21
  }
22
22
  }
23
23
 
24
- // Mulberry32 is ±388% faster than Math.random()
24
+ // Mulberry32 is ±392% faster than Math.random()
25
25
  // Benchmark: https://jsbm.dev/muLCWR9RJCbmy
26
26
  // Spectral test: /demo/mulberry32.html
27
27
  const prng = Mulberry32(Math.random() * 2 ** 32).next
@@ -31,8 +31,11 @@ declare const __VERSION__: string
31
31
  export default class CanvasParticles {
32
32
  static readonly version = __VERSION__
33
33
 
34
+ private static readonly MAX_DT = 1000 / 50 // milliseconds between updates @ 50 FPS
35
+ private static readonly BASE_DT = 1000 / 60 // milliseconds between updates @ 60 FPS
36
+
34
37
  /** Defines mouse interaction types with the particles */
35
- static interactionType = Object.freeze({
38
+ static readonly interactionType = Object.freeze({
36
39
  NONE: 0, // No mouse interaction
37
40
  SHIFT: 1, // Visual displacement only
38
41
  MOVE: 2, // Actual particle movement
@@ -71,12 +74,11 @@ export default class CanvasParticles {
71
74
  }
72
75
  })
73
76
 
74
- private static readonly MAX_DT = 1000 / 30 // milliseconds between updates @ 30 FPS
75
- private static readonly BASE_DT = 1000 / 60 // milliseconds between updates @ 60 FPS
76
77
  /** Helper functions for options parsing */
77
- private static defaultIfNaN = (value: number, defaultValue: number): number => (isNaN(+value) ? defaultValue : +value)
78
+ private static readonly defaultIfNaN = (value: number, defaultValue: number): number =>
79
+ isNaN(+value) ? defaultValue : +value
78
80
 
79
- private static parseNumericOption = (
81
+ private static readonly parseNumericOption = (
80
82
  name: string,
81
83
  value: number | undefined,
82
84
  defaultValue: number,
@@ -88,9 +90,7 @@ export default class CanvasParticles {
88
90
 
89
91
  if (isFinite(min) && value < min) {
90
92
  console.warn(new RangeError(`option.${name} was clamped to ${min} as ${value} is too low`))
91
- }
92
-
93
- if (isFinite(max) && value > max) {
93
+ } else if (isFinite(max) && value > max) {
94
94
  console.warn(new RangeError(`option.${name} was clamped to ${max} as ${value} is too high`))
95
95
  }
96
96
 
@@ -281,11 +281,12 @@ export default class CanvasParticles {
281
281
 
282
282
  const len = this.particleCount
283
283
  const particles = this.particles
284
- const gravRepulsiveMult = this.option.particles.connectDist * this.option.gravity.repulsive
285
- const gravPullingMult = this.option.particles.connectDist * this.option.gravity.pulling
286
- const maxRepulsiveDist = this.option.particles.connectDist / 2
287
- const maxRepulsiveDistSq = maxRepulsiveDist ** 2
288
- const maxGrav = this.option.particles.connectDist * 0.1
284
+ const connectDist = this.option.particles.connectDist
285
+ const gravRepulsiveMult = connectDist * this.option.gravity.repulsive * step
286
+ const gravPullingMult = connectDist * this.option.gravity.pulling * step
287
+ const maxRepulsiveDist = connectDist / 2
288
+ const maxRepulsiveDistSq = maxRepulsiveDist * maxRepulsiveDist
289
+ const eps = (connectDist * connectDist) / 256
289
290
 
290
291
  for (let i = 0; i < len; i++) {
291
292
  const particleA = particles[i]
@@ -298,19 +299,19 @@ export default class CanvasParticles {
298
299
  const distY = particleA.posY - particleB.posY
299
300
  const distSq = distX * distX + distY * distY
300
301
 
302
+ if (distSq >= maxRepulsiveDistSq && !isPullingEnabled) continue
303
+
301
304
  let angle
302
305
  let grav
303
306
  let gravMult
304
307
 
305
- if (distSq >= maxRepulsiveDistSq && !isPullingEnabled) continue
306
-
307
- angle = Math.atan2(particleB.posY - particleA.posY, particleB.posX - particleA.posX)
308
- grav = (1 / Math.sqrt(distSq)) ** 1.8
308
+ angle = Math.atan2(-distY, -distX)
309
+ grav = 1 / (distSq + eps)
309
310
  const angleX = Math.cos(angle)
310
311
  const angleY = Math.sin(angle)
311
312
 
312
313
  if (distSq < maxRepulsiveDistSq) {
313
- gravMult = Math.min(maxGrav, grav * gravRepulsiveMult)
314
+ gravMult = grav * gravRepulsiveMult
314
315
  const gravX = angleX * gravMult
315
316
  const gravY = angleY * gravMult
316
317
  particleA.velX -= gravX
@@ -321,7 +322,7 @@ export default class CanvasParticles {
321
322
 
322
323
  if (!isPullingEnabled) continue
323
324
 
324
- gravMult = Math.min(maxGrav, grav * gravPullingMult)
325
+ gravMult = grav * gravPullingMult
325
326
  const gravX = angleX * gravMult
326
327
  const gravY = angleY * gravMult
327
328
  particleA.velX += gravX
@@ -342,17 +343,18 @@ export default class CanvasParticles {
342
343
  const offY = this.offY
343
344
  const mouseX = this.mouseX
344
345
  const mouseY = this.mouseY
345
- const rotationSpeed = this.option.particles.rotationSpeed
346
+ const rotationSpeed = this.option.particles.rotationSpeed * step
346
347
  const friction = this.option.gravity.friction
347
348
  const mouseConnectDist = this.option.mouse.connectDist
348
349
  const mouseDistRatio = this.option.mouse.distRatio
349
350
  const isMouseInteractionTypeNone = this.option.mouse.interactionType === CanvasParticles.interactionType.NONE
350
351
  const isMouseInteractionTypeMove = this.option.mouse.interactionType === CanvasParticles.interactionType.MOVE
352
+ const easing = 1 - Math.pow(1 - 1 / 4, step)
351
353
 
352
354
  for (let i = 0; i < len; i++) {
353
355
  const particle = particles[i]
354
356
 
355
- particle.dir += 2 * (Math.random() - 0.5) * rotationSpeed
357
+ particle.dir += 2 * (Math.random() - 0.5) * rotationSpeed * step
356
358
  particle.dir %= TWO_PI
357
359
 
358
360
  // Constant velocity
@@ -360,8 +362,8 @@ export default class CanvasParticles {
360
362
  const movY = Math.cos(particle.dir) * particle.speed
361
363
 
362
364
  // Apply velocities
363
- particle.posX += movX + particle.velX
364
- particle.posY += movY + particle.velY
365
+ particle.posX += (movX + particle.velX) * step
366
+ particle.posY += (movY + particle.velY) * step
365
367
 
366
368
  // Wrap particles around the canvas
367
369
  particle.posX %= width
@@ -371,8 +373,8 @@ export default class CanvasParticles {
371
373
  if (particle.posY < 0) particle.posY += height
372
374
 
373
375
  // Slightly decrease dynamic velocity
374
- particle.velX *= friction
375
- particle.velY *= friction
376
+ particle.velX *= Math.pow(friction, step)
377
+ particle.velY *= Math.pow(friction, step)
376
378
 
377
379
  // Distance from mouse
378
380
  const distX = particle.posX + offX - mouseX
@@ -383,11 +385,11 @@ export default class CanvasParticles {
383
385
  const distRatio = mouseConnectDist / Math.hypot(distX, distY)
384
386
 
385
387
  if (mouseDistRatio < distRatio) {
386
- particle.offX += (distRatio * distX - distX - particle.offX) / 4
387
- particle.offY += (distRatio * distY - distY - particle.offY) / 4
388
+ particle.offX += (distRatio * distX - distX - particle.offX) * easing
389
+ particle.offY += (distRatio * distY - distY - particle.offY) * easing
388
390
  } else {
389
- particle.offX -= particle.offX / 4
390
- particle.offY -= particle.offY / 4
391
+ particle.offX -= particle.offX * easing
392
+ particle.offY -= particle.offY * easing
391
393
  }
392
394
  }
393
395
 
@@ -472,9 +474,9 @@ export default class CanvasParticles {
472
474
  const particles = this.particles
473
475
  const ctx = this.ctx
474
476
  const maxDist = this.option.particles.connectDist
475
- const maxDistSq = maxDist ** 2
477
+ const maxDistSq = maxDist * maxDist
476
478
  const halfMaxDist = maxDist / 2
477
- const halfMaxDistSq = halfMaxDist ** 2
479
+ const halfMaxDistSq = halfMaxDist * halfMaxDist
478
480
  const drawAll = maxDist >= Math.min(this.canvas.width, this.canvas.height)
479
481
  const maxWorkPerParticle = maxDistSq * this.option.particles.maxWork
480
482
  const alpha = this.color.alpha
@@ -492,7 +494,7 @@ export default class CanvasParticles {
492
494
  const particleB = particles[j]
493
495
 
494
496
  // Don't draw the line if it wouldn't be visible
495
- if (!(drawAll || this.#isLineVisible(particleA, particleB))) continue
497
+ if (!drawAll && !this.#isLineVisible(particleA, particleB)) continue
496
498
 
497
499
  const distX = particleA.x - particleB.x
498
500
  const distY = particleA.y - particleB.y
@@ -544,7 +546,7 @@ export default class CanvasParticles {
544
546
  this.ctx.lineWidth = 1
545
547
 
546
548
  this.#renderParticles()
547
- this.#renderConnections()
549
+ if (this.options.particles.drawLines) this.#renderConnections()
548
550
  }
549
551
 
550
552
  /** @private Main animation loop that updates and renders the particles */
@@ -562,7 +564,7 @@ export default class CanvasParticles {
562
564
  // - step = 1 → exactly one baseline update (dt === BASE_DT)
563
565
  // - step > 1 → more time passed (lower FPS), advance further
564
566
  // - step < 1 → less time passed (higher FPS), advance less
565
- const step = CanvasParticles.BASE_DT / dt
567
+ const step = dt / CanvasParticles.BASE_DT
566
568
 
567
569
  this.#updateGravity(step)
568
570
  this.#updateParticles(step)
@@ -620,20 +622,26 @@ export default class CanvasParticles {
620
622
  stopOnLeave: !!(options.animation?.stopOnLeave ?? true),
621
623
  },
622
624
  mouse: {
623
- interactionType: pno('mouse.interactionType', options.mouse?.interactionType, 1),
625
+ interactionType: ~~pno(
626
+ 'mouse.interactionType',
627
+ options.mouse?.interactionType,
628
+ CanvasParticles.interactionType.MOVE,
629
+ { min: 0, max: 2 }
630
+ ),
624
631
  connectDistMult: pno('mouse.connectDistMult', options.mouse?.connectDistMult, 2 / 3),
625
632
  connectDist: 1 /* post processed */,
626
633
  distRatio: pno('mouse.distRatio', options.mouse?.distRatio, 2 / 3),
627
634
  },
628
635
  particles: {
629
636
  regenerateOnResize: !!options.particles?.regenerateOnResize,
637
+ drawLines: !!(options.particles?.drawLines ?? true),
630
638
  color: options.particles?.color ?? 'black',
631
- ppm: pno('particles.ppm', options.particles?.ppm, 100),
632
- max: pno('particles.max', options.particles?.max, Infinity),
633
- maxWork: pno('particles.maxWork', options.particles?.maxWork, Infinity, { min: 0 }),
634
- connectDist: pno('particles.connectDistance', options.particles?.connectDistance, 150, { min: 1 }),
639
+ ppm: ~~pno('particles.ppm', options.particles?.ppm, 100),
640
+ max: Math.round(pno('particles.max', options.particles?.max, Infinity)),
641
+ maxWork: Math.round(pno('particles.maxWork', options.particles?.maxWork, Infinity, { min: 0 })),
642
+ connectDist: ~~pno('particles.connectDistance', options.particles?.connectDistance, 150, { min: 1 }),
635
643
  relSpeed: pno('particles.relSpeed', options.particles?.relSpeed, 1, { min: 0 }),
636
- relSize: pno('particles.relSize', options.particles?.relSize, 1, { min: 1 }),
644
+ relSize: pno('particles.relSize', options.particles?.relSize, 1, { min: 0 }),
637
645
  rotationSpeed: pno('particles.rotationSpeed', options.particles?.rotationSpeed, 2, { min: 0 }) / 100,
638
646
  },
639
647
  gravity: {
@@ -15,6 +15,7 @@ export interface CanvasParticlesOptions {
15
15
 
16
16
  particles: {
17
17
  regenerateOnResize: boolean
18
+ drawLines: boolean
18
19
  color: string | CanvasGradient | CanvasPattern
19
20
  ppm: number
20
21
  max: number