canvasparticles-js 4.2.4 → 4.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -13
- package/dist/index.cjs +104 -33
- package/dist/index.d.ts +9 -2
- package/dist/index.mjs +104 -33
- package/dist/index.umd.js +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/options.d.ts +2 -2
- package/package.json +6 -5
- package/src/index.ts +116 -33
- package/src/types/index.ts +1 -0
- package/src/types/options.ts +2 -2
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ Creating a fun and interactive background. Colors, interaction and gravity can b
|
|
|
15
15
|
[Implementation](#implementation)<br>
|
|
16
16
|
[Class Instantiation](#class-instantiation)<br>
|
|
17
17
|
[Options](#options)<br>
|
|
18
|
+
[Manually creating particles](#manually-creating-particles)<br>
|
|
18
19
|
[One Pager Example](#one-pager-example)
|
|
19
20
|
|
|
20
21
|
---
|
|
@@ -212,18 +213,18 @@ Your screen resolution and refresh rate will directly impact perfomance!
|
|
|
212
213
|
|
|
213
214
|
### `particles`
|
|
214
215
|
|
|
215
|
-
| Option
|
|
216
|
-
|
|
|
217
|
-
| `particles.
|
|
218
|
-
| `particles.
|
|
219
|
-
| `particles.
|
|
220
|
-
| `particles.
|
|
221
|
-
| `particles.
|
|
222
|
-
| `particles.
|
|
223
|
-
| `particles.
|
|
224
|
-
| `particles.
|
|
225
|
-
| `particles.
|
|
226
|
-
| `particles.drawLines`
|
|
216
|
+
| Option | Type | Default | Description |
|
|
217
|
+
| --------------------------- | ------------- | ---------- | -------------------------------------------------------------------------------------------------------- |
|
|
218
|
+
| `particles.generationType` | `0 \| 1 \| 2` | `false` | Auto-generate particles on initialization and when the canvas resizes. `0 = OFF`, `1 = NEW`, `2 = MATCH` |
|
|
219
|
+
| `particles.color` | `string` | `'black'` | Particle and connection color. Any CSS color format. |
|
|
220
|
+
| `particles.ppm` | `integer` | `100` | Particles per million pixels. _Heavily impacts performance_ |
|
|
221
|
+
| `particles.max` | `integer` | `Infinity` | Maximum number of particles allowed. |
|
|
222
|
+
| `particles.maxWork` | `integer` | `Infinity` | Maximum total connection length per particle. Lower values stabilize performance but may flicker. |
|
|
223
|
+
| `particles.connectDistance` | `integer` | `150` | Maximum distance for particle connections (px). _Heavily impacts performance_ |
|
|
224
|
+
| `particles.relSpeed` | `float` | `1` | Relative particle speed multiplier. |
|
|
225
|
+
| `particles.relSize` | `float` | `1` | Relative particle size multiplier. |
|
|
226
|
+
| `particles.rotationSpeed` | `float` | `2` | Direction change speed. |
|
|
227
|
+
| `particles.drawLines` | `boolean` | `true` | Whether to draw lines between particles. |
|
|
227
228
|
|
|
228
229
|
---
|
|
229
230
|
|
|
@@ -273,13 +274,32 @@ instance.option.particles.ppm = 100
|
|
|
273
274
|
instance.option.particles.max = 300
|
|
274
275
|
```
|
|
275
276
|
|
|
276
|
-
The changes are only applied when one of the following methods is called
|
|
277
|
+
The changes are only applied when one of the following methods is called:
|
|
277
278
|
|
|
278
279
|
```js
|
|
279
280
|
instance.newParticles() // Remove all particles and create the correct amount of new ones
|
|
280
281
|
instance.matchParticleCount() // Add or remove some particles to match the count
|
|
281
282
|
```
|
|
282
283
|
|
|
284
|
+
### Changing particle properties
|
|
285
|
+
|
|
286
|
+
After updating the following options, the particles are **not automatically updated**:
|
|
287
|
+
|
|
288
|
+
- `particles.relSize`
|
|
289
|
+
- `particles.relSpeed`
|
|
290
|
+
|
|
291
|
+
```js
|
|
292
|
+
// Note: the backing field is called `option` not `options`!
|
|
293
|
+
instance.option.particles.relSize = 2
|
|
294
|
+
instance.option.particles.relSpeed = 3
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
The changes are only applied when the following method is called:
|
|
298
|
+
|
|
299
|
+
```js
|
|
300
|
+
instance.updateParticles() // Updates the particle.speed and particle.size properties without regenerating any particles
|
|
301
|
+
```
|
|
302
|
+
|
|
283
303
|
#### Modifying object properties
|
|
284
304
|
|
|
285
305
|
**All** other options can be updated by only modifying the `option` internal field properties, with changes taking effect immediately.
|
|
@@ -297,12 +317,40 @@ instance.option.gravity.repulsive = 1
|
|
|
297
317
|
|
|
298
318
|
To reinitialize all options, pass a new options object to the `options` setter.
|
|
299
319
|
|
|
320
|
+
> Existing particles their properties will not be updated automatically. [Changing particle properties](#changing-particle-properties)
|
|
321
|
+
|
|
300
322
|
```js
|
|
301
323
|
instance.options = { ... }
|
|
302
324
|
```
|
|
303
325
|
|
|
304
326
|
---
|
|
305
327
|
|
|
328
|
+
## Manually creating particles
|
|
329
|
+
|
|
330
|
+
```ts
|
|
331
|
+
createParticle(posX?: number, posY?: number, dir?: number, speed?: number, size?: number)
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
By default `particles.ppm` and `particles.max` are used to auto-generate random particles. This might destroy manually created particles. To fix this, set `particles.generationType` to `MANUAL (0)`.
|
|
335
|
+
|
|
336
|
+
```js
|
|
337
|
+
const canvas = '#my-canvas'
|
|
338
|
+
const options = {
|
|
339
|
+
particles: {
|
|
340
|
+
generationType: CanvasParticles.generationType.MANUAL, // = 0
|
|
341
|
+
rotationSpeed: 0,
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
const instance = new CanvasParticles(canvas, options).start()
|
|
345
|
+
|
|
346
|
+
// Create a horizontal line of particles moving down
|
|
347
|
+
for (let x = 100; x < 300; x += 4) {
|
|
348
|
+
instance.createParticle(x, 100, 0, 1, 5)
|
|
349
|
+
}
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
306
354
|
## One Pager Example
|
|
307
355
|
|
|
308
356
|
```html
|
package/dist/index.cjs
CHANGED
|
@@ -21,14 +21,20 @@ 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.
|
|
24
|
+
static version = "4.3.1";
|
|
25
25
|
static MAX_DT = 1000 / 50; // milliseconds between updates @ 50 FPS
|
|
26
26
|
static BASE_DT = 1000 / 60; // milliseconds between updates @ 60 FPS
|
|
27
27
|
/** Defines mouse interaction types with the particles */
|
|
28
28
|
static interactionType = Object.freeze({
|
|
29
29
|
NONE: 0, // No mouse interaction
|
|
30
30
|
SHIFT: 1, // Visual displacement only
|
|
31
|
-
MOVE: 2, // Actual particle movement
|
|
31
|
+
MOVE: 2, // Actual particle movement (default)
|
|
32
|
+
});
|
|
33
|
+
/** Defines how the particles are auto-generated */
|
|
34
|
+
static generationType = Object.freeze({
|
|
35
|
+
MANUAL: 0, // Never auto-generate particles
|
|
36
|
+
NEW: 1, // Generate particles from scratch
|
|
37
|
+
MATCH: 2, // Add or remove particles to match new count (default)
|
|
32
38
|
});
|
|
33
39
|
/** Observes canvas elements entering or leaving the viewport to start/stop animation */
|
|
34
40
|
static canvasIntersectionObserver = new IntersectionObserver((entries) => {
|
|
@@ -67,10 +73,10 @@ class CanvasParticles {
|
|
|
67
73
|
if (value == undefined)
|
|
68
74
|
return defaultValue;
|
|
69
75
|
const { min = -Infinity, max = Infinity } = clamp ?? {};
|
|
70
|
-
if (
|
|
76
|
+
if (value < min) {
|
|
71
77
|
console.warn(new RangeError(`option.${name} was clamped to ${min} as ${value} is too low`));
|
|
72
78
|
}
|
|
73
|
-
else if (
|
|
79
|
+
else if (value > max) {
|
|
74
80
|
console.warn(new RangeError(`option.${name} was clamped to ${max} as ${value} is too high`));
|
|
75
81
|
}
|
|
76
82
|
return CanvasParticles.defaultIfNaN(Math.min(Math.max(value ?? defaultValue, min), max), defaultValue);
|
|
@@ -81,7 +87,7 @@ class CanvasParticles {
|
|
|
81
87
|
isAnimating = false;
|
|
82
88
|
lastAnimationFrame = 0;
|
|
83
89
|
particles = [];
|
|
84
|
-
|
|
90
|
+
hasManualParticles = false; // set to true once @public createParticle() is used
|
|
85
91
|
clientX = Infinity;
|
|
86
92
|
clientY = Infinity;
|
|
87
93
|
mouseX = Infinity;
|
|
@@ -168,41 +174,92 @@ class CanvasParticles {
|
|
|
168
174
|
this.height = Math.max(height + this.option.particles.connectDist * 2, 1);
|
|
169
175
|
this.offX = (width - this.width) / 2;
|
|
170
176
|
this.offY = (height - this.height) / 2;
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
177
|
+
const generationType = this.option.particles.generationType;
|
|
178
|
+
if (generationType !== CanvasParticles.generationType.MANUAL) {
|
|
179
|
+
if (generationType === CanvasParticles.generationType.NEW || this.particles.length === 0)
|
|
180
|
+
this.newParticles();
|
|
181
|
+
else if (generationType === CanvasParticles.generationType.MATCH)
|
|
182
|
+
this.matchParticleCount({ updateBounds: true });
|
|
183
|
+
}
|
|
175
184
|
if (this.isAnimating)
|
|
176
185
|
this.#render();
|
|
177
186
|
}
|
|
178
187
|
/** @private Update the target number of particles based on the current canvas size and `options.particles.ppm`, capped at `options.particles.max`. */
|
|
179
|
-
#
|
|
188
|
+
#targetParticleCount() {
|
|
180
189
|
// Amount of particles to be created
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (!isFinite(
|
|
190
|
+
let particleCount = Math.round((this.option.particles.ppm * this.width * this.height) / 1_000_000);
|
|
191
|
+
particleCount = Math.min(this.option.particles.max, particleCount);
|
|
192
|
+
if (!isFinite(particleCount))
|
|
184
193
|
throw new RangeError('particleCount must be finite');
|
|
194
|
+
return particleCount | 0;
|
|
185
195
|
}
|
|
186
196
|
/** @public Remove existing particles and generate new ones */
|
|
187
197
|
newParticles() {
|
|
188
|
-
this.#
|
|
189
|
-
this.
|
|
190
|
-
|
|
191
|
-
this.
|
|
198
|
+
const particleCount = this.#targetParticleCount();
|
|
199
|
+
if (this.hasManualParticles) {
|
|
200
|
+
this.particles = this.particles.filter((particle) => particle.manual);
|
|
201
|
+
this.hasManualParticles = this.particles.length > 0;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
this.particles = [];
|
|
205
|
+
}
|
|
206
|
+
for (let i = 0; i < particleCount; i++)
|
|
207
|
+
this.#createParticle();
|
|
192
208
|
}
|
|
193
209
|
/** @public Adjust particle array length to match `options.particles.ppm` */
|
|
194
210
|
matchParticleCount({ updateBounds = false } = {}) {
|
|
195
|
-
this.#
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
211
|
+
const particleCount = this.#targetParticleCount();
|
|
212
|
+
if (this.hasManualParticles) {
|
|
213
|
+
const pruned = [];
|
|
214
|
+
let autoCount = 0;
|
|
215
|
+
// Keep manual particles while pruning automatic particles that exceed `particleCount`
|
|
216
|
+
// Only count automatic particles towards `particledCount`
|
|
217
|
+
for (const particle of this.particles) {
|
|
218
|
+
if (autoCount >= particleCount)
|
|
219
|
+
break;
|
|
220
|
+
if (particle.manual)
|
|
221
|
+
autoCount++;
|
|
222
|
+
pruned.push(particle);
|
|
223
|
+
}
|
|
224
|
+
this.particles = pruned;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
this.particles = this.particles.slice(0, particleCount);
|
|
228
|
+
}
|
|
229
|
+
// Only necessary after resize
|
|
230
|
+
if (updateBounds) {
|
|
231
|
+
for (const particle of this.particles) {
|
|
232
|
+
this.#updateParticleBounds(particle);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
for (let i = this.particles.length; i < particleCount; i++)
|
|
236
|
+
this.#createParticle();
|
|
237
|
+
}
|
|
238
|
+
/** @private Create a random new particle */
|
|
239
|
+
#createParticle() {
|
|
240
|
+
const posX = prng() * this.width;
|
|
241
|
+
const posY = prng() * this.height;
|
|
242
|
+
const particle = {
|
|
243
|
+
posX, // Logical position in pixels
|
|
244
|
+
posY, // Logical position in pixels
|
|
245
|
+
x: posX, // Visual position in pixels
|
|
246
|
+
y: posY, // Visual position in pixels
|
|
247
|
+
velX: 0, // Horizonal speed in pixels per update
|
|
248
|
+
velY: 0, // Vertical speed in pixels per update
|
|
249
|
+
offX: 0, // Horizontal distance from drawn to logical position in pixels
|
|
250
|
+
offY: 0, // Vertical distance from drawn to logical position in pixels
|
|
251
|
+
dir: prng() * TWO_PI, // Direction in radians
|
|
252
|
+
speed: (0.5 + prng() * 0.5) * this.option.particles.relSpeed, // Velocity in pixels per update
|
|
253
|
+
size: (0.5 + Math.pow(prng(), 5) * 2) * this.option.particles.relSize, // Ray in pixels of the particle
|
|
254
|
+
gridPos: { x: 1, y: 1 },
|
|
255
|
+
isVisible: false,
|
|
256
|
+
manual: false,
|
|
257
|
+
};
|
|
258
|
+
this.#updateParticleBounds(particle);
|
|
259
|
+
this.particles.push(particle);
|
|
201
260
|
}
|
|
202
261
|
/** @public Create a new particle with optional parameters */
|
|
203
262
|
createParticle(posX, posY, dir, speed, size) {
|
|
204
|
-
posX = typeof posX === 'number' ? posX - this.offX : prng() * this.width;
|
|
205
|
-
posY = typeof posY === 'number' ? posY - this.offY : prng() * this.height;
|
|
206
263
|
const particle = {
|
|
207
264
|
posX, // Logical position in pixels
|
|
208
265
|
posY, // Logical position in pixels
|
|
@@ -212,14 +269,16 @@ class CanvasParticles {
|
|
|
212
269
|
velY: 0, // Vertical speed in pixels per update
|
|
213
270
|
offX: 0, // Horizontal distance from drawn to logical position in pixels
|
|
214
271
|
offY: 0, // Vertical distance from drawn to logical position in pixels
|
|
215
|
-
dir: dir
|
|
216
|
-
speed: speed
|
|
217
|
-
size: size
|
|
272
|
+
dir: dir, // Direction in radians
|
|
273
|
+
speed: speed, // Velocity in pixels per update
|
|
274
|
+
size: size, // Ray in pixels of the particle
|
|
218
275
|
gridPos: { x: 1, y: 1 },
|
|
219
276
|
isVisible: false,
|
|
277
|
+
manual: true,
|
|
220
278
|
};
|
|
221
279
|
this.#updateParticleBounds(particle);
|
|
222
280
|
this.particles.push(particle);
|
|
281
|
+
this.hasManualParticles = true;
|
|
223
282
|
}
|
|
224
283
|
/** @private Update the visible bounds of a particle */
|
|
225
284
|
#updateParticleBounds(particle) {
|
|
@@ -231,14 +290,26 @@ class CanvasParticles {
|
|
|
231
290
|
left: -particle.size,
|
|
232
291
|
};
|
|
233
292
|
}
|
|
293
|
+
/* @public Randomize speed and size of all particles based on current options */
|
|
294
|
+
updateParticles() {
|
|
295
|
+
const particles = this.particles;
|
|
296
|
+
const len = particles.length;
|
|
297
|
+
const relSpeed = this.option.particles.relSpeed;
|
|
298
|
+
const relSize = this.option.particles.relSize;
|
|
299
|
+
for (let i = 0; i < len; i++) {
|
|
300
|
+
const particle = particles[i];
|
|
301
|
+
particle.speed = (0.5 + prng() * 0.5) * relSpeed;
|
|
302
|
+
particle.size = (0.5 + Math.pow(prng(), 5) * 2) * relSize;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
234
305
|
/** @private Apply gravity forces between particles */
|
|
235
306
|
#updateGravity(step) {
|
|
236
307
|
const isRepulsiveEnabled = this.option.gravity.repulsive > 0;
|
|
237
308
|
const isPullingEnabled = this.option.gravity.pulling > 0;
|
|
238
309
|
if (!isRepulsiveEnabled && !isPullingEnabled)
|
|
239
310
|
return;
|
|
240
|
-
const len = this.particleCount;
|
|
241
311
|
const particles = this.particles;
|
|
312
|
+
const len = particles.length;
|
|
242
313
|
const connectDist = this.option.particles.connectDist;
|
|
243
314
|
const gravRepulsiveMult = connectDist * this.option.gravity.repulsive * step;
|
|
244
315
|
const gravPullingMult = connectDist * this.option.gravity.pulling * step;
|
|
@@ -285,8 +356,8 @@ class CanvasParticles {
|
|
|
285
356
|
}
|
|
286
357
|
/** @private Update positions, directions, and visibility of all particles */
|
|
287
358
|
#updateParticles(step) {
|
|
288
|
-
const len = this.particleCount;
|
|
289
359
|
const particles = this.particles;
|
|
360
|
+
const len = particles.length;
|
|
290
361
|
const width = this.width;
|
|
291
362
|
const height = this.height;
|
|
292
363
|
const offX = this.offX;
|
|
@@ -380,8 +451,8 @@ class CanvasParticles {
|
|
|
380
451
|
}
|
|
381
452
|
/** @private Draw the particles on the canvas */
|
|
382
453
|
#renderParticles() {
|
|
383
|
-
const len = this.particleCount;
|
|
384
454
|
const particles = this.particles;
|
|
455
|
+
const len = particles.length;
|
|
385
456
|
const ctx = this.ctx;
|
|
386
457
|
for (let i = 0; i < len; i++) {
|
|
387
458
|
const particle = particles[i];
|
|
@@ -403,8 +474,8 @@ class CanvasParticles {
|
|
|
403
474
|
}
|
|
404
475
|
/** @private Draw lines between particles if they are close enough */
|
|
405
476
|
#renderConnections() {
|
|
406
|
-
const len = this.particleCount;
|
|
407
477
|
const particles = this.particles;
|
|
478
|
+
const len = particles.length;
|
|
408
479
|
const ctx = this.ctx;
|
|
409
480
|
const maxDist = this.option.particles.connectDist;
|
|
410
481
|
const maxDistSq = maxDist ** 2;
|
|
@@ -538,7 +609,7 @@ class CanvasParticles {
|
|
|
538
609
|
distRatio: pno('mouse.distRatio', options.mouse?.distRatio, 2 / 3, { min: 0 }),
|
|
539
610
|
},
|
|
540
611
|
particles: {
|
|
541
|
-
|
|
612
|
+
generationType: ~~pno('particles.generationType', options.particles?.generationType, CanvasParticles.generationType.MATCH, { min: 0, max: 2 }),
|
|
542
613
|
drawLines: !!(options.particles?.drawLines ?? true),
|
|
543
614
|
color: options.particles?.color ?? 'black',
|
|
544
615
|
ppm: ~~pno('particles.ppm', options.particles?.ppm, 100),
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,12 @@ export default class CanvasParticles {
|
|
|
11
11
|
SHIFT: 1;
|
|
12
12
|
MOVE: 2;
|
|
13
13
|
}>;
|
|
14
|
+
/** Defines how the particles are auto-generated */
|
|
15
|
+
static readonly generationType: Readonly<{
|
|
16
|
+
MANUAL: 0;
|
|
17
|
+
NEW: 1;
|
|
18
|
+
MATCH: 2;
|
|
19
|
+
}>;
|
|
14
20
|
/** Observes canvas elements entering or leaving the viewport to start/stop animation */
|
|
15
21
|
static readonly canvasIntersectionObserver: IntersectionObserver;
|
|
16
22
|
static readonly canvasResizeObserver: ResizeObserver;
|
|
@@ -23,7 +29,7 @@ export default class CanvasParticles {
|
|
|
23
29
|
isAnimating: boolean;
|
|
24
30
|
private lastAnimationFrame;
|
|
25
31
|
particles: Particle[];
|
|
26
|
-
|
|
32
|
+
hasManualParticles: boolean;
|
|
27
33
|
private clientX;
|
|
28
34
|
private clientY;
|
|
29
35
|
mouseX: number;
|
|
@@ -54,7 +60,8 @@ export default class CanvasParticles {
|
|
|
54
60
|
updateBounds?: boolean;
|
|
55
61
|
}): void;
|
|
56
62
|
/** @public Create a new particle with optional parameters */
|
|
57
|
-
createParticle(posX
|
|
63
|
+
createParticle(posX: number, posY: number, dir: number, speed: number, size: number): void;
|
|
64
|
+
updateParticles(): void;
|
|
58
65
|
/** @public Start the particle animation if it was not running before */
|
|
59
66
|
start({ auto }?: {
|
|
60
67
|
auto?: boolean;
|
package/dist/index.mjs
CHANGED
|
@@ -19,14 +19,20 @@ 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.
|
|
22
|
+
static version = "4.3.1";
|
|
23
23
|
static MAX_DT = 1000 / 50; // milliseconds between updates @ 50 FPS
|
|
24
24
|
static BASE_DT = 1000 / 60; // milliseconds between updates @ 60 FPS
|
|
25
25
|
/** Defines mouse interaction types with the particles */
|
|
26
26
|
static interactionType = Object.freeze({
|
|
27
27
|
NONE: 0, // No mouse interaction
|
|
28
28
|
SHIFT: 1, // Visual displacement only
|
|
29
|
-
MOVE: 2, // Actual particle movement
|
|
29
|
+
MOVE: 2, // Actual particle movement (default)
|
|
30
|
+
});
|
|
31
|
+
/** Defines how the particles are auto-generated */
|
|
32
|
+
static generationType = Object.freeze({
|
|
33
|
+
MANUAL: 0, // Never auto-generate particles
|
|
34
|
+
NEW: 1, // Generate particles from scratch
|
|
35
|
+
MATCH: 2, // Add or remove particles to match new count (default)
|
|
30
36
|
});
|
|
31
37
|
/** Observes canvas elements entering or leaving the viewport to start/stop animation */
|
|
32
38
|
static canvasIntersectionObserver = new IntersectionObserver((entries) => {
|
|
@@ -65,10 +71,10 @@ class CanvasParticles {
|
|
|
65
71
|
if (value == undefined)
|
|
66
72
|
return defaultValue;
|
|
67
73
|
const { min = -Infinity, max = Infinity } = clamp ?? {};
|
|
68
|
-
if (
|
|
74
|
+
if (value < min) {
|
|
69
75
|
console.warn(new RangeError(`option.${name} was clamped to ${min} as ${value} is too low`));
|
|
70
76
|
}
|
|
71
|
-
else if (
|
|
77
|
+
else if (value > max) {
|
|
72
78
|
console.warn(new RangeError(`option.${name} was clamped to ${max} as ${value} is too high`));
|
|
73
79
|
}
|
|
74
80
|
return CanvasParticles.defaultIfNaN(Math.min(Math.max(value ?? defaultValue, min), max), defaultValue);
|
|
@@ -79,7 +85,7 @@ class CanvasParticles {
|
|
|
79
85
|
isAnimating = false;
|
|
80
86
|
lastAnimationFrame = 0;
|
|
81
87
|
particles = [];
|
|
82
|
-
|
|
88
|
+
hasManualParticles = false; // set to true once @public createParticle() is used
|
|
83
89
|
clientX = Infinity;
|
|
84
90
|
clientY = Infinity;
|
|
85
91
|
mouseX = Infinity;
|
|
@@ -166,41 +172,92 @@ class CanvasParticles {
|
|
|
166
172
|
this.height = Math.max(height + this.option.particles.connectDist * 2, 1);
|
|
167
173
|
this.offX = (width - this.width) / 2;
|
|
168
174
|
this.offY = (height - this.height) / 2;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
175
|
+
const generationType = this.option.particles.generationType;
|
|
176
|
+
if (generationType !== CanvasParticles.generationType.MANUAL) {
|
|
177
|
+
if (generationType === CanvasParticles.generationType.NEW || this.particles.length === 0)
|
|
178
|
+
this.newParticles();
|
|
179
|
+
else if (generationType === CanvasParticles.generationType.MATCH)
|
|
180
|
+
this.matchParticleCount({ updateBounds: true });
|
|
181
|
+
}
|
|
173
182
|
if (this.isAnimating)
|
|
174
183
|
this.#render();
|
|
175
184
|
}
|
|
176
185
|
/** @private Update the target number of particles based on the current canvas size and `options.particles.ppm`, capped at `options.particles.max`. */
|
|
177
|
-
#
|
|
186
|
+
#targetParticleCount() {
|
|
178
187
|
// Amount of particles to be created
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
if (!isFinite(
|
|
188
|
+
let particleCount = Math.round((this.option.particles.ppm * this.width * this.height) / 1_000_000);
|
|
189
|
+
particleCount = Math.min(this.option.particles.max, particleCount);
|
|
190
|
+
if (!isFinite(particleCount))
|
|
182
191
|
throw new RangeError('particleCount must be finite');
|
|
192
|
+
return particleCount | 0;
|
|
183
193
|
}
|
|
184
194
|
/** @public Remove existing particles and generate new ones */
|
|
185
195
|
newParticles() {
|
|
186
|
-
this.#
|
|
187
|
-
this.
|
|
188
|
-
|
|
189
|
-
this.
|
|
196
|
+
const particleCount = this.#targetParticleCount();
|
|
197
|
+
if (this.hasManualParticles) {
|
|
198
|
+
this.particles = this.particles.filter((particle) => particle.manual);
|
|
199
|
+
this.hasManualParticles = this.particles.length > 0;
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
this.particles = [];
|
|
203
|
+
}
|
|
204
|
+
for (let i = 0; i < particleCount; i++)
|
|
205
|
+
this.#createParticle();
|
|
190
206
|
}
|
|
191
207
|
/** @public Adjust particle array length to match `options.particles.ppm` */
|
|
192
208
|
matchParticleCount({ updateBounds = false } = {}) {
|
|
193
|
-
this.#
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
209
|
+
const particleCount = this.#targetParticleCount();
|
|
210
|
+
if (this.hasManualParticles) {
|
|
211
|
+
const pruned = [];
|
|
212
|
+
let autoCount = 0;
|
|
213
|
+
// Keep manual particles while pruning automatic particles that exceed `particleCount`
|
|
214
|
+
// Only count automatic particles towards `particledCount`
|
|
215
|
+
for (const particle of this.particles) {
|
|
216
|
+
if (autoCount >= particleCount)
|
|
217
|
+
break;
|
|
218
|
+
if (particle.manual)
|
|
219
|
+
autoCount++;
|
|
220
|
+
pruned.push(particle);
|
|
221
|
+
}
|
|
222
|
+
this.particles = pruned;
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
this.particles = this.particles.slice(0, particleCount);
|
|
226
|
+
}
|
|
227
|
+
// Only necessary after resize
|
|
228
|
+
if (updateBounds) {
|
|
229
|
+
for (const particle of this.particles) {
|
|
230
|
+
this.#updateParticleBounds(particle);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (let i = this.particles.length; i < particleCount; i++)
|
|
234
|
+
this.#createParticle();
|
|
235
|
+
}
|
|
236
|
+
/** @private Create a random new particle */
|
|
237
|
+
#createParticle() {
|
|
238
|
+
const posX = prng() * this.width;
|
|
239
|
+
const posY = prng() * this.height;
|
|
240
|
+
const particle = {
|
|
241
|
+
posX, // Logical position in pixels
|
|
242
|
+
posY, // Logical position in pixels
|
|
243
|
+
x: posX, // Visual position in pixels
|
|
244
|
+
y: posY, // Visual position in pixels
|
|
245
|
+
velX: 0, // Horizonal speed in pixels per update
|
|
246
|
+
velY: 0, // Vertical speed in pixels per update
|
|
247
|
+
offX: 0, // Horizontal distance from drawn to logical position in pixels
|
|
248
|
+
offY: 0, // Vertical distance from drawn to logical position in pixels
|
|
249
|
+
dir: prng() * TWO_PI, // Direction in radians
|
|
250
|
+
speed: (0.5 + prng() * 0.5) * this.option.particles.relSpeed, // Velocity in pixels per update
|
|
251
|
+
size: (0.5 + Math.pow(prng(), 5) * 2) * this.option.particles.relSize, // Ray in pixels of the particle
|
|
252
|
+
gridPos: { x: 1, y: 1 },
|
|
253
|
+
isVisible: false,
|
|
254
|
+
manual: false,
|
|
255
|
+
};
|
|
256
|
+
this.#updateParticleBounds(particle);
|
|
257
|
+
this.particles.push(particle);
|
|
199
258
|
}
|
|
200
259
|
/** @public Create a new particle with optional parameters */
|
|
201
260
|
createParticle(posX, posY, dir, speed, size) {
|
|
202
|
-
posX = typeof posX === 'number' ? posX - this.offX : prng() * this.width;
|
|
203
|
-
posY = typeof posY === 'number' ? posY - this.offY : prng() * this.height;
|
|
204
261
|
const particle = {
|
|
205
262
|
posX, // Logical position in pixels
|
|
206
263
|
posY, // Logical position in pixels
|
|
@@ -210,14 +267,16 @@ class CanvasParticles {
|
|
|
210
267
|
velY: 0, // Vertical speed in pixels per update
|
|
211
268
|
offX: 0, // Horizontal distance from drawn to logical position in pixels
|
|
212
269
|
offY: 0, // Vertical distance from drawn to logical position in pixels
|
|
213
|
-
dir: dir
|
|
214
|
-
speed: speed
|
|
215
|
-
size: size
|
|
270
|
+
dir: dir, // Direction in radians
|
|
271
|
+
speed: speed, // Velocity in pixels per update
|
|
272
|
+
size: size, // Ray in pixels of the particle
|
|
216
273
|
gridPos: { x: 1, y: 1 },
|
|
217
274
|
isVisible: false,
|
|
275
|
+
manual: true,
|
|
218
276
|
};
|
|
219
277
|
this.#updateParticleBounds(particle);
|
|
220
278
|
this.particles.push(particle);
|
|
279
|
+
this.hasManualParticles = true;
|
|
221
280
|
}
|
|
222
281
|
/** @private Update the visible bounds of a particle */
|
|
223
282
|
#updateParticleBounds(particle) {
|
|
@@ -229,14 +288,26 @@ class CanvasParticles {
|
|
|
229
288
|
left: -particle.size,
|
|
230
289
|
};
|
|
231
290
|
}
|
|
291
|
+
/* @public Randomize speed and size of all particles based on current options */
|
|
292
|
+
updateParticles() {
|
|
293
|
+
const particles = this.particles;
|
|
294
|
+
const len = particles.length;
|
|
295
|
+
const relSpeed = this.option.particles.relSpeed;
|
|
296
|
+
const relSize = this.option.particles.relSize;
|
|
297
|
+
for (let i = 0; i < len; i++) {
|
|
298
|
+
const particle = particles[i];
|
|
299
|
+
particle.speed = (0.5 + prng() * 0.5) * relSpeed;
|
|
300
|
+
particle.size = (0.5 + Math.pow(prng(), 5) * 2) * relSize;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
232
303
|
/** @private Apply gravity forces between particles */
|
|
233
304
|
#updateGravity(step) {
|
|
234
305
|
const isRepulsiveEnabled = this.option.gravity.repulsive > 0;
|
|
235
306
|
const isPullingEnabled = this.option.gravity.pulling > 0;
|
|
236
307
|
if (!isRepulsiveEnabled && !isPullingEnabled)
|
|
237
308
|
return;
|
|
238
|
-
const len = this.particleCount;
|
|
239
309
|
const particles = this.particles;
|
|
310
|
+
const len = particles.length;
|
|
240
311
|
const connectDist = this.option.particles.connectDist;
|
|
241
312
|
const gravRepulsiveMult = connectDist * this.option.gravity.repulsive * step;
|
|
242
313
|
const gravPullingMult = connectDist * this.option.gravity.pulling * step;
|
|
@@ -283,8 +354,8 @@ class CanvasParticles {
|
|
|
283
354
|
}
|
|
284
355
|
/** @private Update positions, directions, and visibility of all particles */
|
|
285
356
|
#updateParticles(step) {
|
|
286
|
-
const len = this.particleCount;
|
|
287
357
|
const particles = this.particles;
|
|
358
|
+
const len = particles.length;
|
|
288
359
|
const width = this.width;
|
|
289
360
|
const height = this.height;
|
|
290
361
|
const offX = this.offX;
|
|
@@ -378,8 +449,8 @@ class CanvasParticles {
|
|
|
378
449
|
}
|
|
379
450
|
/** @private Draw the particles on the canvas */
|
|
380
451
|
#renderParticles() {
|
|
381
|
-
const len = this.particleCount;
|
|
382
452
|
const particles = this.particles;
|
|
453
|
+
const len = particles.length;
|
|
383
454
|
const ctx = this.ctx;
|
|
384
455
|
for (let i = 0; i < len; i++) {
|
|
385
456
|
const particle = particles[i];
|
|
@@ -401,8 +472,8 @@ class CanvasParticles {
|
|
|
401
472
|
}
|
|
402
473
|
/** @private Draw lines between particles if they are close enough */
|
|
403
474
|
#renderConnections() {
|
|
404
|
-
const len = this.particleCount;
|
|
405
475
|
const particles = this.particles;
|
|
476
|
+
const len = particles.length;
|
|
406
477
|
const ctx = this.ctx;
|
|
407
478
|
const maxDist = this.option.particles.connectDist;
|
|
408
479
|
const maxDistSq = maxDist ** 2;
|
|
@@ -536,7 +607,7 @@ class CanvasParticles {
|
|
|
536
607
|
distRatio: pno('mouse.distRatio', options.mouse?.distRatio, 2 / 3, { min: 0 }),
|
|
537
608
|
},
|
|
538
609
|
particles: {
|
|
539
|
-
|
|
610
|
+
generationType: ~~pno('particles.generationType', options.particles?.generationType, CanvasParticles.generationType.MATCH, { min: 0, max: 2 }),
|
|
540
611
|
drawLines: !!(options.particles?.drawLines ?? true),
|
|
541
612
|
color: options.particles?.color ?? 'black',
|
|
542
613
|
ppm: ~~pno('particles.ppm', options.particles?.ppm, 100),
|
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.2.4";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})}},{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=[];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)**2,h=o**2/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,l=i.posY-t.posY,p=s*s+l*l;if(p>=c&&!e)continue;let u,d,m;u=Math.atan2(-l,-s),d=1/(p+h);const f=Math.cos(u),v=Math.sin(u);if(p<c){m=d*a;const e=f*m,s=v*m;i.velX-=e,i.velY-=s,t.velX+=e,t.velY+=s}if(!e)continue;m=d*r;const g=f*m,y=v*m;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,m=this.option.mouse.distRatio,f=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,M=s.posY+c-l;if(!f){const t=d/Math.hypot(b,M);m<t?(s.offX+=(t*b-b-s.offX)*g,s.offY+=(t*M-M-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**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,m=p.y-t.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(t.x,t.y),e.stroke()):l.push([p.x,p.y,t.x,t.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:{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,{min:0})),maxWork:Math.round(i("particles.maxWork",t.particles?.maxWork,1/0,{min:0})),connectDist:~~i("particles.connectDistance",t.particles?.connectDistance,150,{min:1}),relSpeed:i("particles.relSpeed",t.particles?.relSpeed,1,{min:0}),relSize:i("particles.relSize",t.particles?.relSize,1,{min:0}),rotationSpeed:i("particles.rotationSpeed",t.particles?.rotationSpeed,2,{min:0})/100},gravity:{repulsive:i("gravity.repulsive",t.gravity?.repulsive,0,{min:0}),pulling:i("gravity.pulling",t.gravity?.pulling,0,{min:0}),friction:i("gravity.friction",t.gravity?.friction,.8,{min:0,max:1})}},this.setBackground(this.option.background),this.setMouseConnectDistMult(this.option.mouse.connectDistMult),this.setParticleColor(this.option.particles.color)}get options(){return this.option}setBackground(t){if(t){if("string"!=typeof t)throw new TypeError("background is not a string");this.canvas.style.background=this.option.background=t}}setMouseConnectDistMult(t){const i=e.parseNumericOption("mouse.connectDistMult",t,2/3,{min:0});this.option.mouse.connectDist=this.option.particles.connectDist*i}setParticleColor(t){if(this.ctx.fillStyle=t,"#"===String(this.ctx.fillStyle)[0])this.color={hex:String(this.ctx.fillStyle),alpha:1};else{let t=String(this.ctx.fillStyle).split(",").at(-1);t=t?.slice(1,-1)??"1",this.ctx.fillStyle=String(this.ctx.fillStyle).split(",").slice(0,-1).join(",")+", 1)",this.color={hex:String(this.ctx.fillStyle),alpha:isNaN(+t)?1:+t}}}}return e});
|
|
1
|
+
!function(t,i){"object"==typeof exports&&"undefined"!=typeof module?module.exports=i():"function"==typeof define&&define.amd?define(i):(t="undefined"!=typeof globalThis?globalThis:t||self).CanvasParticles=i()}(this,function(){"use strict";const t=2*Math.PI;const i=function(t){let i=t>>>0;return{next(){let t=i+1831565813|0;return i=t,t=Math.imul(t^t>>>15,1|t),t^=t+Math.imul(t^t>>>7,61|t),((t^t>>>14)>>>0)/4294967296}}}(Math.random()*2**32).next;class e{static version="4.3.1";static MAX_DT=20;static BASE_DT=1e3/60;static interactionType=Object.freeze({NONE:0,SHIFT:1,MOVE:2});static generationType=Object.freeze({MANUAL:0,NEW:1,MATCH:2});static canvasIntersectionObserver=new IntersectionObserver(t=>{for(let i=0;i<t.length;i++){const e=t[i],s=e.target,n=s.instance;if(!n.options?.animation)return;(s.inViewbox=e.isIntersecting)?n.options.animation?.startOnEnter&&n.start({auto:!0}):n.options.animation?.stopOnLeave&&n.stop({auto:!0,clear:!1})}},{rootMargin:"-1px"});static canvasResizeObserver=new ResizeObserver(t=>{for(let i=0;i<t.length;i++){t[i].target.instance.updateCanvasRect()}for(let i=0;i<t.length;i++){t[i].target.instance.resizeCanvas()}});static defaultIfNaN=(t,i)=>isNaN(+t)?i:+t;static parseNumericOption=(t,i,s,n)=>{if(null==i)return s;const{min:o=-1/0,max:a=1/0}=n??{};return i<o?console.warn(new RangeError(`option.${t} was clamped to ${o} as ${i} is too low`)):i>a&&console.warn(new RangeError(`option.${t} was clamped to ${a} as ${i} is too high`)),e.defaultIfNaN(Math.min(Math.max(i??s,o),a),s)};canvas;ctx;enableAnimating=!1;isAnimating=!1;lastAnimationFrame=0;particles=[];hasManualParticles=!1;clientX=1/0;clientY=1/0;mouseX=1/0;mouseY=1/0;width;height;offX;offY;option;color;constructor(t,i={}){let s;if(t instanceof HTMLCanvasElement)s=t;else{if("string"!=typeof t)throw new TypeError("selector is not a string and neither a HTMLCanvasElement itself");if(s=document.querySelector(t),!(s instanceof HTMLCanvasElement))throw new Error("selector does not point to a canvas")}this.canvas=s,this.canvas.instance=this,this.canvas.inViewbox=!0;const n=this.canvas.getContext("2d");if(!n)throw new Error("failed to get 2D context from canvas");this.ctx=n,this.options=i,e.canvasIntersectionObserver.observe(this.canvas),e.canvasResizeObserver.observe(this.canvas),this.resizeCanvas=this.resizeCanvas.bind(this),this.handleMouseMove=this.handleMouseMove.bind(this),this.handleScroll=this.handleScroll.bind(this),this.updateCanvasRect(),this.resizeCanvas(),window.addEventListener("mousemove",this.handleMouseMove,{passive:!0}),window.addEventListener("scroll",this.handleScroll,{passive:!0})}updateCanvasRect(){const{top:t,left:i,width:e,height:s}=this.canvas.getBoundingClientRect();this.canvas.rect={top:t,left:i,width:e,height:s}}handleMouseMove(t){this.enableAnimating&&(this.clientX=t.clientX,this.clientY=t.clientY,this.isAnimating&&this.updateMousePos())}handleScroll(){this.enableAnimating&&(this.updateCanvasRect(),this.isAnimating&&this.updateMousePos())}updateMousePos(){const{top:t,left:i}=this.canvas.rect;this.mouseX=this.clientX-i,this.mouseY=this.clientY-t}resizeCanvas(){const t=this.canvas.width=this.canvas.rect.width,i=this.canvas.height=this.canvas.rect.height;this.mouseX=1/0,this.mouseY=1/0,this.width=Math.max(t+2*this.option.particles.connectDist,1),this.height=Math.max(i+2*this.option.particles.connectDist,1),this.offX=(t-this.width)/2,this.offY=(i-this.height)/2;const s=this.option.particles.generationType;s!==e.generationType.MANUAL&&(s===e.generationType.NEW||0===this.particles.length?this.newParticles():s===e.generationType.MATCH&&this.matchParticleCount({updateBounds:!0})),this.isAnimating&&this.#t()}#i(){let t=Math.round(this.option.particles.ppm*this.width*this.height/1e6);if(t=Math.min(this.option.particles.max,t),!isFinite(t))throw new RangeError("particleCount must be finite");return 0|t}newParticles(){const t=this.#i();this.hasManualParticles?(this.particles=this.particles.filter(t=>t.manual),this.hasManualParticles=this.particles.length>0):this.particles=[];for(let i=0;i<t;i++)this.#e()}matchParticleCount({updateBounds:t=!1}={}){const i=this.#i();if(this.hasManualParticles){const t=[];let e=0;for(const s of this.particles){if(e>=i)break;s.manual&&e++,t.push(s)}this.particles=t}else this.particles=this.particles.slice(0,i);if(t)for(const t of this.particles)this.#s(t);for(let t=this.particles.length;t<i;t++)this.#e()}#e(){const e=i()*this.width,s=i()*this.height,n={posX:e,posY:s,x:e,y:s,velX:0,velY:0,offX:0,offY:0,dir:i()*t,speed:(.5+.5*i())*this.option.particles.relSpeed,size:(.5+2*Math.pow(i(),5))*this.option.particles.relSize,gridPos:{x:1,y:1},isVisible:!1,manual:!1};this.#s(n),this.particles.push(n)}createParticle(t,i,e,s,n){const o={posX:t,posY:i,x:t,y:i,velX:0,velY:0,offX:0,offY:0,dir:e,speed:s,size:n,gridPos:{x:1,y:1},isVisible:!1,manual:!0};this.#s(o),this.particles.push(o),this.hasManualParticles=!0}#s(t){t.bounds={top:-t.size,right:this.canvas.width+t.size,bottom:this.canvas.height+t.size,left:-t.size}}updateParticles(){const t=this.particles,e=t.length,s=this.option.particles.relSpeed,n=this.option.particles.relSize;for(let o=0;o<e;o++){const e=t[o];e.speed=(.5+.5*i())*s,e.size=(.5+2*Math.pow(i(),5))*n}}#n(t){const i=this.option.gravity.repulsive>0,e=this.option.gravity.pulling>0;if(!i&&!e)return;const s=this.particles,n=s.length,o=this.option.particles.connectDist,a=o*this.option.gravity.repulsive*t,r=o*this.option.gravity.pulling*t,c=(o/2)**2,l=o**2/256;for(let t=0;t<n;t++){const i=s[t];for(let o=t+1;o<n;o++){const t=s[o],n=i.posX-t.posX,h=i.posY-t.posY,p=n*n+h*h;if(p>=c&&!e)continue;let u,d,m;u=Math.atan2(-h,-n),d=1/(p+l);const f=Math.cos(u),g=Math.sin(u);if(p<c){m=d*a;const e=f*m,s=g*m;i.velX-=e,i.velY-=s,t.velX+=e,t.velY+=s}if(!e)continue;m=d*r;const v=f*m,y=g*m;i.velX+=v,i.velY+=y,t.velX-=v,t.velY-=y}}}#o(i){const s=this.particles,n=s.length,o=this.width,a=this.height,r=this.offX,c=this.offY,l=this.mouseX,h=this.mouseY,p=this.option.particles.rotationSpeed*i,u=this.option.gravity.friction,d=this.option.mouse.connectDist,m=this.option.mouse.distRatio,f=this.option.mouse.interactionType===e.interactionType.NONE,g=this.option.mouse.interactionType===e.interactionType.MOVE,v=1-Math.pow(.75,i);for(let e=0;e<n;e++){const n=s[e];n.dir+=2*(Math.random()-.5)*p*i,n.dir%=t;const y=Math.sin(n.dir)*n.speed,x=Math.cos(n.dir)*n.speed;n.posX+=(y+n.velX)*i,n.posY+=(x+n.velY)*i,n.posX%=o,n.posX<0&&(n.posX+=o),n.posY%=a,n.posY<0&&(n.posY+=a),n.velX*=Math.pow(u,i),n.velY*=Math.pow(u,i);const M=n.posX+r-l,b=n.posY+c-h;if(!f){const t=d/Math.hypot(M,b);m<t?(n.offX+=(t*M-M-n.offX)*v,n.offY+=(t*b-b-n.offY)*v):(n.offX-=n.offX*v,n.offY-=n.offY*v)}n.x=n.posX+n.offX,n.y=n.posY+n.offY,g&&(n.posX=n.x,n.posY=n.y),n.x+=r,n.y+=c,this.#a(n),n.isVisible=1===n.gridPos.x&&1===n.gridPos.y}}#a(t){t.gridPos.x=+(t.x>=t.bounds.left)+ +(t.x>t.bounds.right),t.gridPos.y=+(t.y>=t.bounds.top)+ +(t.y>t.bounds.bottom)}#r(t,i){return!(!t.isVisible&&!i.isVisible)||!(t.gridPos.x===i.gridPos.x&&1!==t.gridPos.x||t.gridPos.y===i.gridPos.y&&1!==t.gridPos.y)}#c(){const i=this.particles,e=i.length,s=this.ctx;for(let n=0;n<e;n++){const e=i[n];e.isVisible&&(e.size>1?(s.beginPath(),s.arc(e.x,e.y,e.size,0,t),s.fill(),s.closePath()):s.fillRect(e.x-e.size,e.y-e.size,2*e.size,2*e.size))}}#l(){const t=this.particles,i=t.length,e=this.ctx,s=this.option.particles.connectDist,n=s**2,o=(s/2)**2,a=s>=Math.min(this.canvas.width,this.canvas.height),r=n*this.option.particles.maxWork,c=this.color.alpha,l=this.color.alpha*s,h=[];for(let s=0;s<i;s++){const p=t[s];let u=0;for(let d=s+1;d<i;d++){const i=t[d];if(!a&&!this.#r(p,i))continue;const s=p.x-i.x,m=p.y-i.y,f=s*s+m*m;if(!(f>n)&&(f>o?(e.globalAlpha=l/Math.sqrt(f)-c,e.beginPath(),e.moveTo(p.x,p.y),e.lineTo(i.x,i.y),e.stroke()):h.push([p.x,p.y,i.x,i.y]),(u+=f)>=r))break}}if(h.length){e.globalAlpha=c,e.beginPath();for(let t=0;t<h.length;t++){const i=h[t];e.moveTo(i[0],i[1]),e.lineTo(i[2],i[3])}e.stroke()}}#t(){this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height),this.ctx.globalAlpha=this.color.alpha,this.ctx.fillStyle=this.color.hex,this.ctx.strokeStyle=this.color.hex,this.ctx.lineWidth=1,this.#c(),this.options.particles.drawLines&&this.#l()}#h(){if(!this.isAnimating)return;requestAnimationFrame(()=>this.#h());const t=performance.now(),i=Math.min(t-this.lastAnimationFrame,e.MAX_DT)/e.BASE_DT;this.#n(i),this.#o(i),this.#t(),this.lastAnimationFrame=t}start({auto:t=!1}={}){return this.isAnimating||t&&!this.enableAnimating||(this.enableAnimating=!0,this.isAnimating=!0,this.updateCanvasRect(),requestAnimationFrame(()=>this.#h())),!this.canvas.inViewbox&&this.option.animation.startOnEnter&&(this.isAnimating=!1),this}stop({auto:t=!1,clear:i=!0}={}){return t||(this.enableAnimating=!1),this.isAnimating=!1,!1!==i&&this.ctx.clearRect(0,0,this.canvas.width,this.canvas.height),!0}destroy(){this.stop(),e.canvasIntersectionObserver.unobserve(this.canvas),e.canvasResizeObserver.unobserve(this.canvas),window.removeEventListener("mousemove",this.handleMouseMove),window.removeEventListener("scroll",this.handleScroll),this.canvas?.remove(),Object.keys(this).forEach(t=>delete this[t])}set options(t){const i=e.parseNumericOption;this.option={background:t.background??!1,animation:{startOnEnter:!!(t.animation?.startOnEnter??1),stopOnLeave:!!(t.animation?.stopOnLeave??1)},mouse:{interactionType:~~i("mouse.interactionType",t.mouse?.interactionType,e.interactionType.MOVE,{min:0,max:2}),connectDistMult:i("mouse.connectDistMult",t.mouse?.connectDistMult,2/3,{min:0}),connectDist:1,distRatio:i("mouse.distRatio",t.mouse?.distRatio,2/3,{min:0})},particles:{generationType:~~i("particles.generationType",t.particles?.generationType,e.generationType.MATCH,{min:0,max:2}),drawLines:!!(t.particles?.drawLines??1),color:t.particles?.color??"black",ppm:~~i("particles.ppm",t.particles?.ppm,100),max:Math.round(i("particles.max",t.particles?.max,1/0,{min:0})),maxWork:Math.round(i("particles.maxWork",t.particles?.maxWork,1/0,{min:0})),connectDist:~~i("particles.connectDistance",t.particles?.connectDistance,150,{min:1}),relSpeed:i("particles.relSpeed",t.particles?.relSpeed,1,{min:0}),relSize:i("particles.relSize",t.particles?.relSize,1,{min:0}),rotationSpeed:i("particles.rotationSpeed",t.particles?.rotationSpeed,2,{min:0})/100},gravity:{repulsive:i("gravity.repulsive",t.gravity?.repulsive,0,{min:0}),pulling:i("gravity.pulling",t.gravity?.pulling,0,{min:0}),friction:i("gravity.friction",t.gravity?.friction,.8,{min:0,max:1})}},this.setBackground(this.option.background),this.setMouseConnectDistMult(this.option.mouse.connectDistMult),this.setParticleColor(this.option.particles.color)}get options(){return this.option}setBackground(t){if(t){if("string"!=typeof t)throw new TypeError("background is not a string");this.canvas.style.background=this.option.background=t}}setMouseConnectDistMult(t){const i=e.parseNumericOption("mouse.connectDistMult",t,2/3,{min:0});this.option.mouse.connectDist=this.option.particles.connectDist*i}setParticleColor(t){if(this.ctx.fillStyle=t,"#"===String(this.ctx.fillStyle)[0])this.color={hex:String(this.ctx.fillStyle),alpha:1};else{let t=String(this.ctx.fillStyle).split(",").at(-1);t=t?.slice(1,-1)??"1",this.ctx.fillStyle=String(this.ctx.fillStyle).split(",").slice(0,-1).join(",")+", 1)",this.color={hex:String(this.ctx.fillStyle),alpha:isNaN(+t)?1:+t}}}}return e});
|
package/dist/types/index.d.ts
CHANGED
package/dist/types/options.d.ts
CHANGED
|
@@ -5,13 +5,13 @@ export interface CanvasParticlesOptions {
|
|
|
5
5
|
stopOnLeave: boolean;
|
|
6
6
|
};
|
|
7
7
|
mouse: {
|
|
8
|
-
interactionType:
|
|
8
|
+
interactionType: 0 | 1 | 2;
|
|
9
9
|
connectDistMult: number;
|
|
10
10
|
connectDist: number;
|
|
11
11
|
distRatio: number;
|
|
12
12
|
};
|
|
13
13
|
particles: {
|
|
14
|
-
|
|
14
|
+
generationType: 0 | 1 | 2;
|
|
15
15
|
drawLines: boolean;
|
|
16
16
|
color: string | CanvasGradient | CanvasPattern;
|
|
17
17
|
ppm: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canvasparticles-js",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.1",
|
|
4
4
|
"description": "In an HTML canvas, a bunch of interactive particles connected with lines when they approach each other.",
|
|
5
5
|
"author": "Khoeckman",
|
|
6
6
|
"license": "MIT",
|
|
@@ -29,10 +29,11 @@
|
|
|
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.
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
"rollup
|
|
32
|
+
"@types/node": "^24.10.4",
|
|
33
|
+
"pnpm": "^10.27.0",
|
|
34
|
+
"prettier": "^3.7.4",
|
|
35
|
+
"rollup": "^4.54.0",
|
|
36
|
+
"rollup-plugin-delete": "^3.0.2",
|
|
36
37
|
"tslib": "^2.8.1",
|
|
37
38
|
"typescript": "^5.9.3"
|
|
38
39
|
},
|
package/src/index.ts
CHANGED
|
@@ -38,7 +38,14 @@ export default class CanvasParticles {
|
|
|
38
38
|
static readonly interactionType = Object.freeze({
|
|
39
39
|
NONE: 0, // No mouse interaction
|
|
40
40
|
SHIFT: 1, // Visual displacement only
|
|
41
|
-
MOVE: 2, // Actual particle movement
|
|
41
|
+
MOVE: 2, // Actual particle movement (default)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/** Defines how the particles are auto-generated */
|
|
45
|
+
static readonly generationType = Object.freeze({
|
|
46
|
+
MANUAL: 0, // Never auto-generate particles
|
|
47
|
+
NEW: 1, // Generate particles from scratch
|
|
48
|
+
MATCH: 2, // Add or remove particles to match new count (default)
|
|
42
49
|
})
|
|
43
50
|
|
|
44
51
|
/** Observes canvas elements entering or leaving the viewport to start/stop animation */
|
|
@@ -93,9 +100,9 @@ export default class CanvasParticles {
|
|
|
93
100
|
|
|
94
101
|
const { min = -Infinity, max = Infinity } = clamp ?? {}
|
|
95
102
|
|
|
96
|
-
if (
|
|
103
|
+
if (value < min) {
|
|
97
104
|
console.warn(new RangeError(`option.${name} was clamped to ${min} as ${value} is too low`))
|
|
98
|
-
} else if (
|
|
105
|
+
} else if (value > max) {
|
|
99
106
|
console.warn(new RangeError(`option.${name} was clamped to ${max} as ${value} is too high`))
|
|
100
107
|
}
|
|
101
108
|
|
|
@@ -110,8 +117,7 @@ export default class CanvasParticles {
|
|
|
110
117
|
private lastAnimationFrame: number = 0
|
|
111
118
|
|
|
112
119
|
particles: Particle[] = []
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
hasManualParticles = false // set to true once @public createParticle() is used
|
|
115
121
|
private clientX: number = Infinity
|
|
116
122
|
private clientY: number = Infinity
|
|
117
123
|
mouseX: number = Infinity
|
|
@@ -210,42 +216,74 @@ export default class CanvasParticles {
|
|
|
210
216
|
this.offX = (width - this.width) / 2
|
|
211
217
|
this.offY = (height - this.height) / 2
|
|
212
218
|
|
|
213
|
-
|
|
214
|
-
|
|
219
|
+
const generationType = this.option.particles.generationType
|
|
220
|
+
|
|
221
|
+
if (generationType !== CanvasParticles.generationType.MANUAL) {
|
|
222
|
+
if (generationType === CanvasParticles.generationType.NEW || this.particles.length === 0) this.newParticles()
|
|
223
|
+
else if (generationType === CanvasParticles.generationType.MATCH) this.matchParticleCount({ updateBounds: true })
|
|
224
|
+
}
|
|
215
225
|
|
|
216
226
|
if (this.isAnimating) this.#render()
|
|
217
227
|
}
|
|
218
228
|
|
|
219
229
|
/** @private Update the target number of particles based on the current canvas size and `options.particles.ppm`, capped at `options.particles.max`. */
|
|
220
|
-
#
|
|
230
|
+
#targetParticleCount(): number {
|
|
221
231
|
// Amount of particles to be created
|
|
222
|
-
|
|
223
|
-
|
|
232
|
+
let particleCount = Math.round((this.option.particles.ppm * this.width * this.height) / 1_000_000)
|
|
233
|
+
particleCount = Math.min(this.option.particles.max, particleCount)
|
|
224
234
|
|
|
225
|
-
if (!isFinite(
|
|
235
|
+
if (!isFinite(particleCount)) throw new RangeError('particleCount must be finite')
|
|
236
|
+
return particleCount | 0
|
|
226
237
|
}
|
|
227
238
|
|
|
228
239
|
/** @public Remove existing particles and generate new ones */
|
|
229
240
|
newParticles() {
|
|
230
|
-
this.#
|
|
241
|
+
const particleCount = this.#targetParticleCount()
|
|
231
242
|
|
|
232
|
-
this.
|
|
233
|
-
|
|
243
|
+
if (this.hasManualParticles) {
|
|
244
|
+
this.particles = this.particles.filter((particle) => particle.manual)
|
|
245
|
+
this.hasManualParticles = this.particles.length > 0
|
|
246
|
+
} else {
|
|
247
|
+
this.particles = []
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
for (let i = 0; i < particleCount; i++) this.#createParticle()
|
|
234
251
|
}
|
|
235
252
|
|
|
236
253
|
/** @public Adjust particle array length to match `options.particles.ppm` */
|
|
237
254
|
matchParticleCount({ updateBounds = false }: { updateBounds?: boolean } = {}) {
|
|
238
|
-
this.#
|
|
255
|
+
const particleCount = this.#targetParticleCount()
|
|
256
|
+
|
|
257
|
+
if (this.hasManualParticles) {
|
|
258
|
+
const pruned: Particle[] = []
|
|
259
|
+
let autoCount = 0
|
|
260
|
+
|
|
261
|
+
// Keep manual particles while pruning automatic particles that exceed `particleCount`
|
|
262
|
+
// Only count automatic particles towards `particledCount`
|
|
263
|
+
for (const particle of this.particles) {
|
|
264
|
+
if (autoCount >= particleCount) break
|
|
265
|
+
if (particle.manual) autoCount++
|
|
266
|
+
pruned.push(particle)
|
|
267
|
+
}
|
|
268
|
+
this.particles = pruned
|
|
269
|
+
} else {
|
|
270
|
+
this.particles = this.particles.slice(0, particleCount)
|
|
271
|
+
}
|
|
239
272
|
|
|
240
|
-
|
|
241
|
-
if (updateBounds)
|
|
242
|
-
|
|
273
|
+
// Only necessary after resize
|
|
274
|
+
if (updateBounds) {
|
|
275
|
+
for (const particle of this.particles) {
|
|
276
|
+
this.#updateParticleBounds(particle)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
for (let i = this.particles.length; i < particleCount; i++) this.#createParticle()
|
|
243
281
|
}
|
|
244
282
|
|
|
245
|
-
/** @
|
|
246
|
-
createParticle(
|
|
247
|
-
posX =
|
|
248
|
-
posY =
|
|
283
|
+
/** @private Create a random new particle */
|
|
284
|
+
#createParticle() {
|
|
285
|
+
const posX = prng() * this.width
|
|
286
|
+
const posY = prng() * this.height
|
|
249
287
|
|
|
250
288
|
const particle: Omit<Particle, 'bounds'> = {
|
|
251
289
|
posX, // Logical position in pixels
|
|
@@ -256,18 +294,44 @@ export default class CanvasParticles {
|
|
|
256
294
|
velY: 0, // Vertical speed in pixels per update
|
|
257
295
|
offX: 0, // Horizontal distance from drawn to logical position in pixels
|
|
258
296
|
offY: 0, // Vertical distance from drawn to logical position in pixels
|
|
259
|
-
dir:
|
|
260
|
-
speed:
|
|
261
|
-
size:
|
|
297
|
+
dir: prng() * TWO_PI, // Direction in radians
|
|
298
|
+
speed: (0.5 + prng() * 0.5) * this.option.particles.relSpeed, // Velocity in pixels per update
|
|
299
|
+
size: (0.5 + Math.pow(prng(), 5) * 2) * this.option.particles.relSize, // Ray in pixels of the particle
|
|
262
300
|
gridPos: { x: 1, y: 1 },
|
|
263
301
|
isVisible: false,
|
|
302
|
+
manual: false,
|
|
264
303
|
}
|
|
265
304
|
this.#updateParticleBounds(particle)
|
|
266
|
-
this.particles.push(particle
|
|
305
|
+
this.particles.push(particle)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** @public Create a new particle with optional parameters */
|
|
309
|
+
createParticle(posX: number, posY: number, dir: number, speed: number, size: number) {
|
|
310
|
+
const particle: Omit<Particle, 'bounds'> = {
|
|
311
|
+
posX, // Logical position in pixels
|
|
312
|
+
posY, // Logical position in pixels
|
|
313
|
+
x: posX, // Visual position in pixels
|
|
314
|
+
y: posY, // Visual position in pixels
|
|
315
|
+
velX: 0, // Horizonal speed in pixels per update
|
|
316
|
+
velY: 0, // Vertical speed in pixels per update
|
|
317
|
+
offX: 0, // Horizontal distance from drawn to logical position in pixels
|
|
318
|
+
offY: 0, // Vertical distance from drawn to logical position in pixels
|
|
319
|
+
dir: dir, // Direction in radians
|
|
320
|
+
speed: speed, // Velocity in pixels per update
|
|
321
|
+
size: size, // Ray in pixels of the particle
|
|
322
|
+
gridPos: { x: 1, y: 1 },
|
|
323
|
+
isVisible: false,
|
|
324
|
+
manual: true,
|
|
325
|
+
}
|
|
326
|
+
this.#updateParticleBounds(particle)
|
|
327
|
+
this.particles.push(particle)
|
|
328
|
+
this.hasManualParticles = true
|
|
267
329
|
}
|
|
268
330
|
|
|
269
331
|
/** @private Update the visible bounds of a particle */
|
|
270
|
-
#updateParticleBounds(
|
|
332
|
+
#updateParticleBounds(
|
|
333
|
+
particle: Omit<Particle, 'bounds'> & Partial<Pick<Particle, 'bounds'>>
|
|
334
|
+
): asserts particle is Particle {
|
|
271
335
|
// The particle is considered visible within these bounds
|
|
272
336
|
particle.bounds = {
|
|
273
337
|
top: -particle.size,
|
|
@@ -277,6 +341,20 @@ export default class CanvasParticles {
|
|
|
277
341
|
}
|
|
278
342
|
}
|
|
279
343
|
|
|
344
|
+
/* @public Randomize speed and size of all particles based on current options */
|
|
345
|
+
updateParticles() {
|
|
346
|
+
const particles = this.particles
|
|
347
|
+
const len = particles.length
|
|
348
|
+
const relSpeed = this.option.particles.relSpeed
|
|
349
|
+
const relSize = this.option.particles.relSize
|
|
350
|
+
|
|
351
|
+
for (let i = 0; i < len; i++) {
|
|
352
|
+
const particle = particles[i]
|
|
353
|
+
particle.speed = (0.5 + prng() * 0.5) * relSpeed
|
|
354
|
+
particle.size = (0.5 + Math.pow(prng(), 5) * 2) * relSize
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
280
358
|
/** @private Apply gravity forces between particles */
|
|
281
359
|
#updateGravity(step: number) {
|
|
282
360
|
const isRepulsiveEnabled = this.option.gravity.repulsive > 0
|
|
@@ -284,8 +362,8 @@ export default class CanvasParticles {
|
|
|
284
362
|
|
|
285
363
|
if (!isRepulsiveEnabled && !isPullingEnabled) return
|
|
286
364
|
|
|
287
|
-
const len = this.particleCount
|
|
288
365
|
const particles = this.particles
|
|
366
|
+
const len = particles.length
|
|
289
367
|
const connectDist = this.option.particles.connectDist
|
|
290
368
|
const gravRepulsiveMult = connectDist * this.option.gravity.repulsive * step
|
|
291
369
|
const gravPullingMult = connectDist * this.option.gravity.pulling * step
|
|
@@ -340,8 +418,8 @@ export default class CanvasParticles {
|
|
|
340
418
|
|
|
341
419
|
/** @private Update positions, directions, and visibility of all particles */
|
|
342
420
|
#updateParticles(step: number) {
|
|
343
|
-
const len = this.particleCount
|
|
344
421
|
const particles = this.particles
|
|
422
|
+
const len = particles.length
|
|
345
423
|
const width = this.width
|
|
346
424
|
const height = this.height
|
|
347
425
|
const offX = this.offX
|
|
@@ -450,8 +528,8 @@ export default class CanvasParticles {
|
|
|
450
528
|
|
|
451
529
|
/** @private Draw the particles on the canvas */
|
|
452
530
|
#renderParticles() {
|
|
453
|
-
const len = this.particleCount
|
|
454
531
|
const particles = this.particles
|
|
532
|
+
const len = particles.length
|
|
455
533
|
const ctx = this.ctx
|
|
456
534
|
|
|
457
535
|
for (let i = 0; i < len; i++) {
|
|
@@ -475,8 +553,8 @@ export default class CanvasParticles {
|
|
|
475
553
|
|
|
476
554
|
/** @private Draw lines between particles if they are close enough */
|
|
477
555
|
#renderConnections() {
|
|
478
|
-
const len = this.particleCount
|
|
479
556
|
const particles = this.particles
|
|
557
|
+
const len = particles.length
|
|
480
558
|
const ctx = this.ctx
|
|
481
559
|
const maxDist = this.option.particles.connectDist
|
|
482
560
|
const maxDistSq = maxDist ** 2
|
|
@@ -632,13 +710,18 @@ export default class CanvasParticles {
|
|
|
632
710
|
options.mouse?.interactionType,
|
|
633
711
|
CanvasParticles.interactionType.MOVE,
|
|
634
712
|
{ min: 0, max: 2 }
|
|
635
|
-
),
|
|
713
|
+
) as 0 | 1 | 2,
|
|
636
714
|
connectDistMult: pno('mouse.connectDistMult', options.mouse?.connectDistMult, 2 / 3, { min: 0 }),
|
|
637
715
|
connectDist: 1 /* post processed */,
|
|
638
716
|
distRatio: pno('mouse.distRatio', options.mouse?.distRatio, 2 / 3, { min: 0 }),
|
|
639
717
|
},
|
|
640
718
|
particles: {
|
|
641
|
-
|
|
719
|
+
generationType: ~~pno(
|
|
720
|
+
'particles.generationType',
|
|
721
|
+
options.particles?.generationType,
|
|
722
|
+
CanvasParticles.generationType.MATCH,
|
|
723
|
+
{ min: 0, max: 2 }
|
|
724
|
+
) as 0 | 1 | 2,
|
|
642
725
|
drawLines: !!(options.particles?.drawLines ?? true),
|
|
643
726
|
color: options.particles?.color ?? 'black',
|
|
644
727
|
ppm: ~~pno('particles.ppm', options.particles?.ppm, 100),
|
package/src/types/index.ts
CHANGED
package/src/types/options.ts
CHANGED
|
@@ -7,14 +7,14 @@ export interface CanvasParticlesOptions {
|
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
mouse: {
|
|
10
|
-
interactionType:
|
|
10
|
+
interactionType: 0 | 1 | 2 /* see CanvasParticles.interactionType */
|
|
11
11
|
connectDistMult: number
|
|
12
12
|
connectDist: number /* post processed */
|
|
13
13
|
distRatio: number
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
particles: {
|
|
17
|
-
|
|
17
|
+
generationType: 0 | 1 | 2 /* see CanvasParticles.generationType */
|
|
18
18
|
drawLines: boolean
|
|
19
19
|
color: string | CanvasGradient | CanvasPattern
|
|
20
20
|
ppm: number
|