canvasparticles-js 3.4.6 → 3.5.0
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 +14 -5
- package/canvasParticles.js +145 -85
- package/canvasParticles.mjs +145 -85
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -175,13 +175,17 @@ const options = {
|
|
|
175
175
|
/** @param {integer} [options.framesPerUpdate=1] - How many times the same frame will be shown before an update happens.
|
|
176
176
|
* @example 60 fps / 2 framesPerUpdate = 30 updates/s
|
|
177
177
|
* @example 144 fps / 3 framesPerUpdate = 48 updates/s
|
|
178
|
-
|
|
178
|
+
*/
|
|
179
179
|
framesPerUpdate: 1, // recommended: 1 - 3
|
|
180
180
|
|
|
181
|
-
/** @param {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
181
|
+
/** @param {Object} [options.animation] - Animation settings. */
|
|
182
|
+
animation: {
|
|
183
|
+
/** @param {boolean} [options.animation.startOnEnter=true] - Whether to start the animation when the canvas enters the viewport. */
|
|
184
|
+
startOnEnter: true,
|
|
185
|
+
|
|
186
|
+
/** @param {boolean} [options.animation.stopOnLeave=true] - Whether to stop the animation when the canvas leaves the viewport. */
|
|
187
|
+
stopOnLeave: true,
|
|
188
|
+
},
|
|
185
189
|
|
|
186
190
|
/** @param {Object} [options.mouse] - Mouse interaction settings. */
|
|
187
191
|
mouse: {
|
|
@@ -247,6 +251,11 @@ const options = {
|
|
|
247
251
|
* @example 1 rotationSpeed = max direction change of 0.01 radians per update
|
|
248
252
|
*/
|
|
249
253
|
rotationSpeed: 1, // recommended: < 10
|
|
254
|
+
|
|
255
|
+
/** @param {boolean} [options.particles.regenerateOnResize=false] - Create new particles when the canvas gets resized.
|
|
256
|
+
* @note If false, will instead add or remove a few particles to match particles.ppm
|
|
257
|
+
*/
|
|
258
|
+
regenerateOnResize: false,
|
|
250
259
|
},
|
|
251
260
|
|
|
252
261
|
/** @param {Object} [options.gravity] - Gravitational force settings.
|
package/canvasParticles.js
CHANGED
|
@@ -9,7 +9,18 @@
|
|
|
9
9
|
typeof self !== 'undefined' ? self : this,
|
|
10
10
|
() =>
|
|
11
11
|
class CanvasParticles {
|
|
12
|
-
static version = '3.
|
|
12
|
+
static version = '3.5.0'
|
|
13
|
+
|
|
14
|
+
// Start or stop the animation when the canvas enters or exits the viewport
|
|
15
|
+
static canvasObserver = new IntersectionObserver(entry => {
|
|
16
|
+
entry.forEach(change => {
|
|
17
|
+
// CanvasParticles instance of the target canvas
|
|
18
|
+
const instance = change.target.instance
|
|
19
|
+
|
|
20
|
+
if (change.isIntersecting) instance.options.animation.startOnEnter && instance.start()
|
|
21
|
+
else instance.options.animation.stopOnLeave && instance.stop({ clear: false })
|
|
22
|
+
})
|
|
23
|
+
})
|
|
13
24
|
|
|
14
25
|
/**
|
|
15
26
|
* Creates a new CanvasParticles instance.
|
|
@@ -23,6 +34,8 @@
|
|
|
23
34
|
this.canvas = document.querySelector(selector)
|
|
24
35
|
if (!(this.canvas instanceof HTMLCanvasElement)) throw new Error('selector does not point to a canvas')
|
|
25
36
|
|
|
37
|
+
this.canvas.instance = this
|
|
38
|
+
|
|
26
39
|
// Get 2d drawing functions
|
|
27
40
|
this.ctx = this.canvas.getContext('2d')
|
|
28
41
|
|
|
@@ -30,47 +43,64 @@
|
|
|
30
43
|
this.particles = []
|
|
31
44
|
this.setOptions(options)
|
|
32
45
|
|
|
33
|
-
|
|
34
|
-
window.addEventListener('resize', this.#resizeCanvas)
|
|
35
|
-
this.#resizeCanvas()
|
|
46
|
+
CanvasParticles.canvasObserver.observe(this.canvas)
|
|
36
47
|
|
|
37
|
-
|
|
38
|
-
window.addEventListener('scroll', this.#updateMousePos)
|
|
48
|
+
this.#setupEventHandlers()
|
|
39
49
|
}
|
|
40
50
|
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
// Helper function
|
|
52
|
+
#defaultIfNaN(value, defaultValue) {
|
|
53
|
+
return isNaN(+value) ? defaultValue : +value
|
|
54
|
+
}
|
|
43
55
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
this.
|
|
56
|
+
#setupEventHandlers() {
|
|
57
|
+
const updateMousePos = event => {
|
|
58
|
+
if (!this.animating) return
|
|
59
|
+
|
|
60
|
+
if (event instanceof MouseEvent) {
|
|
61
|
+
this.clientX = event.clientX
|
|
62
|
+
this.clientY = event.clientY
|
|
63
|
+
}
|
|
64
|
+
const { left, top } = this.canvas.getBoundingClientRect()
|
|
65
|
+
this.mouseX = this.clientX - left
|
|
66
|
+
this.mouseY = this.clientY - top
|
|
47
67
|
}
|
|
48
|
-
const { left, top } = this.canvas.getBoundingClientRect()
|
|
49
|
-
this.mouseX = this.clientX - left
|
|
50
|
-
this.mouseY = this.clientY - top
|
|
51
|
-
}
|
|
52
68
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
69
|
+
const resizeCanvas = () => {
|
|
70
|
+
this.canvas.width = this.canvas.offsetWidth
|
|
71
|
+
this.canvas.height = this.canvas.offsetHeight
|
|
56
72
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
73
|
+
// Prevent the mouse acting like it's at (x: 0, y: 0) before it has moved.
|
|
74
|
+
this.mouseX = Infinity
|
|
75
|
+
this.mouseY = Infinity
|
|
60
76
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
this.updateCount = Infinity
|
|
78
|
+
this.width = this.canvas.width + this.options.particles.connectDist * 2
|
|
79
|
+
this.height = this.canvas.height + this.options.particles.connectDist * 2
|
|
80
|
+
this.offX = (this.canvas.width - this.width) / 2
|
|
81
|
+
this.offY = (this.canvas.height - this.height) / 2
|
|
66
82
|
|
|
67
|
-
|
|
68
|
-
|
|
83
|
+
if (this.options.particles.regenerateOnResize || this.particles.length === 0) this.newParticles()
|
|
84
|
+
else this.matchParticleCount()
|
|
69
85
|
|
|
70
|
-
|
|
86
|
+
this.#updateParticleBounds()
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
window.addEventListener('resize', resizeCanvas)
|
|
90
|
+
resizeCanvas()
|
|
91
|
+
|
|
92
|
+
window.addEventListener('mousemove', updateMousePos)
|
|
93
|
+
window.addEventListener('scroll', updateMousePos)
|
|
71
94
|
}
|
|
72
95
|
|
|
73
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Update the target number of particles based on the current canvas size and 'options.particles.ppm'
|
|
98
|
+
* Capped at 'options.particles.max'.
|
|
99
|
+
*
|
|
100
|
+
* @private
|
|
101
|
+
* @throws {RangeError} If the particle count is not finite.
|
|
102
|
+
*/
|
|
103
|
+
#updateParticleCount() {
|
|
74
104
|
// Amount of particles to be created
|
|
75
105
|
const particleCount = ~~((this.options.particles.ppm * this.width * this.height) / 1_000_000)
|
|
76
106
|
this.particleCount = Math.min(this.options.particles.max, particleCount)
|
|
@@ -80,26 +110,26 @@
|
|
|
80
110
|
|
|
81
111
|
/**
|
|
82
112
|
* Remove all particles and generate new ones.
|
|
83
|
-
* The amount of new particles will match 'options.particles.ppm'
|
|
113
|
+
* The amount of new particles will match 'options.particles.ppm'.
|
|
84
114
|
* */
|
|
85
|
-
newParticles
|
|
86
|
-
this.#
|
|
115
|
+
newParticles() {
|
|
116
|
+
this.#updateParticleCount()
|
|
87
117
|
|
|
88
118
|
this.particles = []
|
|
89
119
|
for (let i = 0; i < this.particleCount; i++) this.createParticle()
|
|
90
120
|
}
|
|
91
121
|
|
|
92
122
|
/**
|
|
93
|
-
* When resizing, add or remove some particles so that the final amount of particles will match 'options.particles.ppm'
|
|
123
|
+
* When resizing, add or remove some particles so that the final amount of particles will match 'options.particles.ppm'.
|
|
94
124
|
* */
|
|
95
|
-
matchParticleCount
|
|
96
|
-
this.#
|
|
125
|
+
matchParticleCount() {
|
|
126
|
+
this.#updateParticleCount()
|
|
97
127
|
|
|
98
128
|
this.particles = this.particles.slice(0, this.particleCount)
|
|
99
129
|
while (this.particleCount > this.particles.length) this.createParticle()
|
|
100
130
|
}
|
|
101
131
|
|
|
102
|
-
createParticle
|
|
132
|
+
createParticle(posX, posY, dir, speed, size) {
|
|
103
133
|
size = size || 0.5 + Math.random() ** 5 * 2 * this.options.particles.relSize
|
|
104
134
|
|
|
105
135
|
this.particles.push({
|
|
@@ -118,7 +148,7 @@
|
|
|
118
148
|
this.#updateParticleBounds()
|
|
119
149
|
}
|
|
120
150
|
|
|
121
|
-
#updateParticleBounds
|
|
151
|
+
#updateParticleBounds() {
|
|
122
152
|
this.particles.map(
|
|
123
153
|
particle =>
|
|
124
154
|
// Within these bounds the particle is considered visible
|
|
@@ -134,8 +164,10 @@
|
|
|
134
164
|
/**
|
|
135
165
|
* Calculates the gravity properties of each particle on the next frame.
|
|
136
166
|
* Is executed once every 'options.framesPerUpdate' frames.
|
|
167
|
+
*
|
|
168
|
+
* @private
|
|
137
169
|
* */
|
|
138
|
-
#updateGravity
|
|
170
|
+
#updateGravity() {
|
|
139
171
|
const isRepulsiveEnabled = this.options.gravity.repulsive !== 0
|
|
140
172
|
const isPullingEnabled = this.options.gravity.pulling !== 0
|
|
141
173
|
|
|
@@ -194,8 +226,10 @@
|
|
|
194
226
|
/**
|
|
195
227
|
* Calculates the properties of each particle on the next frame.
|
|
196
228
|
* Is executed once every 'options.framesPerUpdate' frames.
|
|
229
|
+
*
|
|
230
|
+
* @private
|
|
197
231
|
* */
|
|
198
|
-
#updateParticles
|
|
232
|
+
#updateParticles() {
|
|
199
233
|
for (let particle of this.particles) {
|
|
200
234
|
// Moving the particle
|
|
201
235
|
particle.dir = (particle.dir + Math.random() * this.options.particles.rotationSpeed * 2 - this.options.particles.rotationSpeed) % (2 * Math.PI)
|
|
@@ -237,7 +271,7 @@
|
|
|
237
271
|
|
|
238
272
|
/**
|
|
239
273
|
* Determines the location of the particle in a 3x3 grid on the canvas.
|
|
240
|
-
* The grid represents different regions of the canvas
|
|
274
|
+
* The grid represents different regions of the canvas:
|
|
241
275
|
*
|
|
242
276
|
* - { x: 0, y: 0 } = top-left
|
|
243
277
|
* - { x: 1, y: 0 } = top
|
|
@@ -249,6 +283,7 @@
|
|
|
249
283
|
* - { x: 1, y: 2 } = bottom
|
|
250
284
|
* - { x: 2, y: 2 } = bottom-right
|
|
251
285
|
*
|
|
286
|
+
* @private
|
|
252
287
|
* @param {Object} particle - The coordinates of the particle.
|
|
253
288
|
* @param {number} particle.x - The x-coordinate of the particle.
|
|
254
289
|
* @param {number} particle.y - The y-coordinate of the particle.
|
|
@@ -256,7 +291,7 @@
|
|
|
256
291
|
* @returns {number} x - The horizontal grid position (0, 1, or 2).
|
|
257
292
|
* @returns {number} y - The vertical grid position (0, 1, or 2).
|
|
258
293
|
*/
|
|
259
|
-
#gridPos
|
|
294
|
+
#gridPos(particle) {
|
|
260
295
|
return {
|
|
261
296
|
x: (particle.x >= particle.bounds.left) + (particle.x > particle.bounds.right),
|
|
262
297
|
y: (particle.y >= particle.bounds.top) + (particle.y > particle.bounds.bottom),
|
|
@@ -265,6 +300,8 @@
|
|
|
265
300
|
|
|
266
301
|
/**
|
|
267
302
|
* Determines whether a line between 2 particles crosses through the visible center of the canvas.
|
|
303
|
+
*
|
|
304
|
+
* @private
|
|
268
305
|
* @param {Object} particleA - First particle with {gridPos, isVisible}.
|
|
269
306
|
* @param {Object} particleB - Second particle with {gridPos, isVisible}.
|
|
270
307
|
* @returns {boolean} - True if the line crosses the visible center, false otherwise.
|
|
@@ -284,6 +321,7 @@
|
|
|
284
321
|
* Precomputes and caches stroke style strings for a given base color and all possible alpha values (0–255).
|
|
285
322
|
* This is necessary because the rendering process involves up to [particles ** 2 / 2] lookups per frame.
|
|
286
323
|
*
|
|
324
|
+
* @private
|
|
287
325
|
* @param {string} color - The base color in the format `#rrggbb`.
|
|
288
326
|
* @returns {Object} - A lookup table mapping each alpha value (0–255) to its corresponding stroke style string in the format `#rrggbbaa`.
|
|
289
327
|
*
|
|
@@ -297,7 +335,7 @@
|
|
|
297
335
|
* hexadecimal alpha value (0x00–0xFF) to the base color.
|
|
298
336
|
* - The table is stored in `this.strokeStyleTable` for quick lookups.
|
|
299
337
|
*/
|
|
300
|
-
#generateStrokeStyleTable
|
|
338
|
+
#generateStrokeStyleTable(color) {
|
|
301
339
|
const table = {}
|
|
302
340
|
|
|
303
341
|
// Precompute stroke styles for alpha values 0–255
|
|
@@ -308,7 +346,12 @@
|
|
|
308
346
|
return table
|
|
309
347
|
}
|
|
310
348
|
|
|
311
|
-
|
|
349
|
+
/**
|
|
350
|
+
* Renders the particles on the canvas.
|
|
351
|
+
*
|
|
352
|
+
* @private
|
|
353
|
+
*/
|
|
354
|
+
#renderParticles() {
|
|
312
355
|
for (let particle of this.particles) {
|
|
313
356
|
if (particle.isVisible) {
|
|
314
357
|
// Draw the particle as a square if the size is smaller than 1 pixel (±183% faster than drawing only circles, using default settings)
|
|
@@ -328,8 +371,10 @@
|
|
|
328
371
|
|
|
329
372
|
/**
|
|
330
373
|
* Connects particles with lines if they are within the connection distance.
|
|
374
|
+
*
|
|
375
|
+
* @private
|
|
331
376
|
*/
|
|
332
|
-
#renderConnections
|
|
377
|
+
#renderConnections() {
|
|
333
378
|
const len = this.particleCount
|
|
334
379
|
const drawAll = this.options.particles.connectDist >= Math.min(this.canvas.width, this.canvas.height)
|
|
335
380
|
|
|
@@ -375,8 +420,10 @@
|
|
|
375
420
|
|
|
376
421
|
/**
|
|
377
422
|
* Clear the canvas and render the particles and their connections onto the canvas.
|
|
423
|
+
*
|
|
424
|
+
* @private
|
|
378
425
|
*/
|
|
379
|
-
#render
|
|
426
|
+
#render() {
|
|
380
427
|
this.canvas.width = this.canvas.width
|
|
381
428
|
this.ctx.fillStyle = this.options.particles.colorWithAlpha
|
|
382
429
|
this.ctx.lineWidth = 1
|
|
@@ -387,9 +434,11 @@
|
|
|
387
434
|
|
|
388
435
|
/**
|
|
389
436
|
* Main animation loop that updates and renders the particles.
|
|
390
|
-
* Runs recursively using
|
|
437
|
+
* Runs recursively using 'requestAnimationFrame'.
|
|
438
|
+
*
|
|
439
|
+
* @private
|
|
391
440
|
*/
|
|
392
|
-
#animation
|
|
441
|
+
#animation() {
|
|
393
442
|
if (!this.animating) return
|
|
394
443
|
|
|
395
444
|
requestAnimationFrame(() => this.#animation())
|
|
@@ -408,20 +457,26 @@
|
|
|
408
457
|
|
|
409
458
|
/**
|
|
410
459
|
* Starts the particle animation.
|
|
411
|
-
*
|
|
460
|
+
* Does nothing if already animating.
|
|
461
|
+
*
|
|
462
|
+
* @returns {CanvasParticles} - The current instance.
|
|
412
463
|
*/
|
|
413
|
-
start
|
|
414
|
-
if (this.animating)
|
|
415
|
-
|
|
416
|
-
|
|
464
|
+
start() {
|
|
465
|
+
if (!this.animating) {
|
|
466
|
+
this.animating = true
|
|
467
|
+
requestAnimationFrame(() => this.#animation())
|
|
468
|
+
}
|
|
469
|
+
return this
|
|
417
470
|
}
|
|
418
471
|
|
|
419
472
|
/**
|
|
420
473
|
* Stops the particle animation and clears the canvas.
|
|
421
474
|
*/
|
|
422
|
-
stop
|
|
475
|
+
stop(options) {
|
|
423
476
|
this.animating = false
|
|
424
|
-
this.canvas.width = this.canvas.width
|
|
477
|
+
if (options?.clear !== false) this.canvas.width = this.canvas.width
|
|
478
|
+
|
|
479
|
+
return true
|
|
425
480
|
}
|
|
426
481
|
|
|
427
482
|
/**
|
|
@@ -429,36 +484,40 @@
|
|
|
429
484
|
*/
|
|
430
485
|
|
|
431
486
|
/**
|
|
432
|
-
* Set and validate the options object
|
|
487
|
+
* Set and validate the options object.
|
|
433
488
|
* @param {Object} options - Object structure: https://github.com/Khoeckman/canvasParticles?tab=readme-ov-file#options
|
|
434
489
|
*/
|
|
435
|
-
setOptions
|
|
436
|
-
const
|
|
490
|
+
setOptions(options) {
|
|
491
|
+
const parse = this.#defaultIfNaN
|
|
437
492
|
|
|
438
493
|
// Format and store options
|
|
439
494
|
this.options = {
|
|
440
495
|
background: options.background ?? false,
|
|
441
|
-
framesPerUpdate:
|
|
442
|
-
|
|
496
|
+
framesPerUpdate: parse(Math.max(1, parseInt(options.framesPerUpdate)), 1),
|
|
497
|
+
animation: {
|
|
498
|
+
startOnEnter: !!(options.animation?.startOnEnter ?? true),
|
|
499
|
+
stopOnLeave: !!(options.animation?.stopOnLeave ?? true),
|
|
500
|
+
},
|
|
443
501
|
mouse: {
|
|
444
|
-
interactionType:
|
|
445
|
-
connectDistMult:
|
|
446
|
-
distRatio:
|
|
502
|
+
interactionType: parse(parseInt(options.mouse?.interactionType), 1),
|
|
503
|
+
connectDistMult: parse(options.mouse?.connectDistMult, 2 / 3),
|
|
504
|
+
distRatio: parse(options.mouse?.distRatio, 2 / 3),
|
|
447
505
|
},
|
|
448
506
|
particles: {
|
|
507
|
+
regenerateOnResize: !!options.particles?.regenerateOnResize,
|
|
449
508
|
color: options.particles?.color ?? 'black',
|
|
450
|
-
ppm:
|
|
451
|
-
max:
|
|
452
|
-
maxWork:
|
|
453
|
-
connectDist:
|
|
454
|
-
relSpeed:
|
|
455
|
-
relSize:
|
|
456
|
-
rotationSpeed:
|
|
509
|
+
ppm: parse(options.particles?.ppm, 100),
|
|
510
|
+
max: parse(options.particles?.max, 500),
|
|
511
|
+
maxWork: parse(Math.max(0, options.particles?.maxWork), Infinity),
|
|
512
|
+
connectDist: parse(Math.max(1, options.particles?.connectDistance), 150),
|
|
513
|
+
relSpeed: parse(Math.max(0, options.particles?.relSpeed), 1),
|
|
514
|
+
relSize: parse(Math.max(0, options.particles?.relSize), 1),
|
|
515
|
+
rotationSpeed: parse(Math.max(0, options.particles?.rotationSpeed / 100), 0.02),
|
|
457
516
|
},
|
|
458
517
|
gravity: {
|
|
459
|
-
repulsive:
|
|
460
|
-
pulling:
|
|
461
|
-
friction:
|
|
518
|
+
repulsive: parse(options.gravity?.repulsive, 0),
|
|
519
|
+
pulling: parse(options.gravity?.pulling, 0),
|
|
520
|
+
friction: parse(Math.max(0, Math.min(1, options.particles?.friction)), 0.8),
|
|
462
521
|
},
|
|
463
522
|
}
|
|
464
523
|
|
|
@@ -468,37 +527,38 @@
|
|
|
468
527
|
}
|
|
469
528
|
|
|
470
529
|
/**
|
|
471
|
-
* Set canvas background
|
|
530
|
+
* Set canvas background.
|
|
472
531
|
* @param {string} background - The style of the background. Can be any CSS supported background format.
|
|
473
532
|
*/
|
|
474
|
-
setBackground
|
|
475
|
-
if (typeof background === 'string') this.canvas.style.background = this.options.background = background
|
|
533
|
+
setBackground(background) {
|
|
534
|
+
if (typeof background === 'string') return (this.canvas.style.background = this.options.background = background), true
|
|
535
|
+
return false
|
|
476
536
|
}
|
|
477
537
|
|
|
478
538
|
/**
|
|
479
|
-
* Transform distance multiplier to absolute distance
|
|
539
|
+
* Transform distance multiplier to absolute distance.
|
|
480
540
|
* @param {float} connectDistMult - The maximum distance for the mouse to interact with the particles.
|
|
481
|
-
* The value is multiplied by particles.connectDistance
|
|
541
|
+
* The value is multiplied by 'particles.connectDistance'.
|
|
482
542
|
* @example 0.8 connectDistMult * 150 particles.connectDistance = 120 pixels
|
|
483
543
|
*/
|
|
484
|
-
setMouseConnectDistMult
|
|
485
|
-
this.options.mouse.connectDist = this.options.particles.connectDist * (
|
|
544
|
+
setMouseConnectDistMult(connectDistMult) {
|
|
545
|
+
this.options.mouse.connectDist = this.options.particles.connectDist * this.#defaultIfNaN(connectDistMult, 2 / 3)
|
|
486
546
|
}
|
|
487
547
|
|
|
488
548
|
/**
|
|
489
|
-
* Format particle color and opacity
|
|
549
|
+
* Format particle color and opacity.
|
|
490
550
|
* @param {string} color - The color of the particles and their connections. Can be any CSS supported color format.
|
|
491
551
|
*/
|
|
492
|
-
setParticleColor
|
|
552
|
+
setParticleColor(color) {
|
|
493
553
|
this.ctx.fillStyle = color
|
|
494
554
|
|
|
495
|
-
// Check if
|
|
555
|
+
// Check if 'ctx.fillStyle' is in hex format ("#RRGGBB" without alpha).
|
|
496
556
|
if (this.ctx.fillStyle[0] === '#') this.options.particles.opacity = 255
|
|
497
557
|
else {
|
|
498
|
-
// JavaScript's
|
|
558
|
+
// JavaScript's 'ctx.fillStyle' ensures the color will otherwise be in rgba format (e.g., "rgba(136, 244, 255, 0.25)")
|
|
499
559
|
|
|
500
560
|
// Extract the alpha value (0.25) from the rgba string, scale it to the range 0x00 to 0xff,
|
|
501
|
-
// and convert it to an integer. This value represents the opacity as a 2-character hex string
|
|
561
|
+
// and convert it to an integer. This value represents the opacity as a 2-character hex string
|
|
502
562
|
this.options.particles.opacity = ~~(this.ctx.fillStyle.split(',').at(-1).slice(1, -1) * 255)
|
|
503
563
|
|
|
504
564
|
// Example: extract 136, 244 and 255 from rgba(136, 244, 255, 0.25) and convert to hexadecimal '#rrggbb' format
|
package/canvasParticles.mjs
CHANGED
|
@@ -2,7 +2,18 @@
|
|
|
2
2
|
// https://github.com/Khoeckman/canvasParticles/blob/main/LICENSE
|
|
3
3
|
|
|
4
4
|
export default class CanvasParticles {
|
|
5
|
-
static version = '3.
|
|
5
|
+
static version = '3.5.0'
|
|
6
|
+
|
|
7
|
+
// Start or stop the animation when the canvas enters or exits the viewport
|
|
8
|
+
static canvasObserver = new IntersectionObserver(entry => {
|
|
9
|
+
entry.forEach(change => {
|
|
10
|
+
// CanvasParticles instance of the target canvas
|
|
11
|
+
const instance = change.target.instance
|
|
12
|
+
|
|
13
|
+
if (change.isIntersecting) instance.options.animation.startOnEnter && instance.start()
|
|
14
|
+
else instance.options.animation.stopOnLeave && instance.stop({ clear: false })
|
|
15
|
+
})
|
|
16
|
+
})
|
|
6
17
|
|
|
7
18
|
/**
|
|
8
19
|
* Creates a new CanvasParticles instance.
|
|
@@ -16,6 +27,8 @@ export default class CanvasParticles {
|
|
|
16
27
|
this.canvas = document.querySelector(selector)
|
|
17
28
|
if (!(this.canvas instanceof HTMLCanvasElement)) throw new Error('selector does not point to a canvas')
|
|
18
29
|
|
|
30
|
+
this.canvas.instance = this
|
|
31
|
+
|
|
19
32
|
// Get 2d drawing functions
|
|
20
33
|
this.ctx = this.canvas.getContext('2d')
|
|
21
34
|
|
|
@@ -23,47 +36,64 @@ export default class CanvasParticles {
|
|
|
23
36
|
this.particles = []
|
|
24
37
|
this.setOptions(options)
|
|
25
38
|
|
|
26
|
-
|
|
27
|
-
window.addEventListener('resize', this.#resizeCanvas)
|
|
28
|
-
this.#resizeCanvas()
|
|
39
|
+
CanvasParticles.canvasObserver.observe(this.canvas)
|
|
29
40
|
|
|
30
|
-
|
|
31
|
-
window.addEventListener('scroll', this.#updateMousePos)
|
|
41
|
+
this.#setupEventHandlers()
|
|
32
42
|
}
|
|
33
43
|
|
|
34
|
-
|
|
35
|
-
|
|
44
|
+
// Helper function
|
|
45
|
+
#defaultIfNaN(value, defaultValue) {
|
|
46
|
+
return isNaN(+value) ? defaultValue : +value
|
|
47
|
+
}
|
|
36
48
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
this.
|
|
49
|
+
#setupEventHandlers() {
|
|
50
|
+
const updateMousePos = event => {
|
|
51
|
+
if (!this.animating) return
|
|
52
|
+
|
|
53
|
+
if (event instanceof MouseEvent) {
|
|
54
|
+
this.clientX = event.clientX
|
|
55
|
+
this.clientY = event.clientY
|
|
56
|
+
}
|
|
57
|
+
const { left, top } = this.canvas.getBoundingClientRect()
|
|
58
|
+
this.mouseX = this.clientX - left
|
|
59
|
+
this.mouseY = this.clientY - top
|
|
40
60
|
}
|
|
41
|
-
const { left, top } = this.canvas.getBoundingClientRect()
|
|
42
|
-
this.mouseX = this.clientX - left
|
|
43
|
-
this.mouseY = this.clientY - top
|
|
44
|
-
}
|
|
45
61
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
const resizeCanvas = () => {
|
|
63
|
+
this.canvas.width = this.canvas.offsetWidth
|
|
64
|
+
this.canvas.height = this.canvas.offsetHeight
|
|
49
65
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
// Prevent the mouse acting like it's at (x: 0, y: 0) before it has moved.
|
|
67
|
+
this.mouseX = Infinity
|
|
68
|
+
this.mouseY = Infinity
|
|
53
69
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
70
|
+
this.updateCount = Infinity
|
|
71
|
+
this.width = this.canvas.width + this.options.particles.connectDist * 2
|
|
72
|
+
this.height = this.canvas.height + this.options.particles.connectDist * 2
|
|
73
|
+
this.offX = (this.canvas.width - this.width) / 2
|
|
74
|
+
this.offY = (this.canvas.height - this.height) / 2
|
|
59
75
|
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
if (this.options.particles.regenerateOnResize || this.particles.length === 0) this.newParticles()
|
|
77
|
+
else this.matchParticleCount()
|
|
62
78
|
|
|
63
|
-
|
|
79
|
+
this.#updateParticleBounds()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
window.addEventListener('resize', resizeCanvas)
|
|
83
|
+
resizeCanvas()
|
|
84
|
+
|
|
85
|
+
window.addEventListener('mousemove', updateMousePos)
|
|
86
|
+
window.addEventListener('scroll', updateMousePos)
|
|
64
87
|
}
|
|
65
88
|
|
|
66
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Update the target number of particles based on the current canvas size and 'options.particles.ppm'
|
|
91
|
+
* Capped at 'options.particles.max'.
|
|
92
|
+
*
|
|
93
|
+
* @private
|
|
94
|
+
* @throws {RangeError} If the particle count is not finite.
|
|
95
|
+
*/
|
|
96
|
+
#updateParticleCount() {
|
|
67
97
|
// Amount of particles to be created
|
|
68
98
|
const particleCount = ~~((this.options.particles.ppm * this.width * this.height) / 1_000_000)
|
|
69
99
|
this.particleCount = Math.min(this.options.particles.max, particleCount)
|
|
@@ -73,26 +103,26 @@ export default class CanvasParticles {
|
|
|
73
103
|
|
|
74
104
|
/**
|
|
75
105
|
* Remove all particles and generate new ones.
|
|
76
|
-
* The amount of new particles will match 'options.particles.ppm'
|
|
106
|
+
* The amount of new particles will match 'options.particles.ppm'.
|
|
77
107
|
* */
|
|
78
|
-
newParticles
|
|
79
|
-
this.#
|
|
108
|
+
newParticles() {
|
|
109
|
+
this.#updateParticleCount()
|
|
80
110
|
|
|
81
111
|
this.particles = []
|
|
82
112
|
for (let i = 0; i < this.particleCount; i++) this.createParticle()
|
|
83
113
|
}
|
|
84
114
|
|
|
85
115
|
/**
|
|
86
|
-
* When resizing, add or remove some particles so that the final amount of particles will match 'options.particles.ppm'
|
|
116
|
+
* When resizing, add or remove some particles so that the final amount of particles will match 'options.particles.ppm'.
|
|
87
117
|
* */
|
|
88
|
-
matchParticleCount
|
|
89
|
-
this.#
|
|
118
|
+
matchParticleCount() {
|
|
119
|
+
this.#updateParticleCount()
|
|
90
120
|
|
|
91
121
|
this.particles = this.particles.slice(0, this.particleCount)
|
|
92
122
|
while (this.particleCount > this.particles.length) this.createParticle()
|
|
93
123
|
}
|
|
94
124
|
|
|
95
|
-
createParticle
|
|
125
|
+
createParticle(posX, posY, dir, speed, size) {
|
|
96
126
|
size = size || 0.5 + Math.random() ** 5 * 2 * this.options.particles.relSize
|
|
97
127
|
|
|
98
128
|
this.particles.push({
|
|
@@ -111,7 +141,7 @@ export default class CanvasParticles {
|
|
|
111
141
|
this.#updateParticleBounds()
|
|
112
142
|
}
|
|
113
143
|
|
|
114
|
-
#updateParticleBounds
|
|
144
|
+
#updateParticleBounds() {
|
|
115
145
|
this.particles.map(
|
|
116
146
|
particle =>
|
|
117
147
|
// Within these bounds the particle is considered visible
|
|
@@ -127,8 +157,10 @@ export default class CanvasParticles {
|
|
|
127
157
|
/**
|
|
128
158
|
* Calculates the gravity properties of each particle on the next frame.
|
|
129
159
|
* Is executed once every 'options.framesPerUpdate' frames.
|
|
160
|
+
*
|
|
161
|
+
* @private
|
|
130
162
|
* */
|
|
131
|
-
#updateGravity
|
|
163
|
+
#updateGravity() {
|
|
132
164
|
const isRepulsiveEnabled = this.options.gravity.repulsive !== 0
|
|
133
165
|
const isPullingEnabled = this.options.gravity.pulling !== 0
|
|
134
166
|
|
|
@@ -187,8 +219,10 @@ export default class CanvasParticles {
|
|
|
187
219
|
/**
|
|
188
220
|
* Calculates the properties of each particle on the next frame.
|
|
189
221
|
* Is executed once every 'options.framesPerUpdate' frames.
|
|
222
|
+
*
|
|
223
|
+
* @private
|
|
190
224
|
* */
|
|
191
|
-
#updateParticles
|
|
225
|
+
#updateParticles() {
|
|
192
226
|
for (let particle of this.particles) {
|
|
193
227
|
// Moving the particle
|
|
194
228
|
particle.dir = (particle.dir + Math.random() * this.options.particles.rotationSpeed * 2 - this.options.particles.rotationSpeed) % (2 * Math.PI)
|
|
@@ -230,7 +264,7 @@ export default class CanvasParticles {
|
|
|
230
264
|
|
|
231
265
|
/**
|
|
232
266
|
* Determines the location of the particle in a 3x3 grid on the canvas.
|
|
233
|
-
* The grid represents different regions of the canvas
|
|
267
|
+
* The grid represents different regions of the canvas:
|
|
234
268
|
*
|
|
235
269
|
* - { x: 0, y: 0 } = top-left
|
|
236
270
|
* - { x: 1, y: 0 } = top
|
|
@@ -242,6 +276,7 @@ export default class CanvasParticles {
|
|
|
242
276
|
* - { x: 1, y: 2 } = bottom
|
|
243
277
|
* - { x: 2, y: 2 } = bottom-right
|
|
244
278
|
*
|
|
279
|
+
* @private
|
|
245
280
|
* @param {Object} particle - The coordinates of the particle.
|
|
246
281
|
* @param {number} particle.x - The x-coordinate of the particle.
|
|
247
282
|
* @param {number} particle.y - The y-coordinate of the particle.
|
|
@@ -249,7 +284,7 @@ export default class CanvasParticles {
|
|
|
249
284
|
* @returns {number} x - The horizontal grid position (0, 1, or 2).
|
|
250
285
|
* @returns {number} y - The vertical grid position (0, 1, or 2).
|
|
251
286
|
*/
|
|
252
|
-
#gridPos
|
|
287
|
+
#gridPos(particle) {
|
|
253
288
|
return {
|
|
254
289
|
x: (particle.x >= particle.bounds.left) + (particle.x > particle.bounds.right),
|
|
255
290
|
y: (particle.y >= particle.bounds.top) + (particle.y > particle.bounds.bottom),
|
|
@@ -258,6 +293,8 @@ export default class CanvasParticles {
|
|
|
258
293
|
|
|
259
294
|
/**
|
|
260
295
|
* Determines whether a line between 2 particles crosses through the visible center of the canvas.
|
|
296
|
+
*
|
|
297
|
+
* @private
|
|
261
298
|
* @param {Object} particleA - First particle with {gridPos, isVisible}.
|
|
262
299
|
* @param {Object} particleB - Second particle with {gridPos, isVisible}.
|
|
263
300
|
* @returns {boolean} - True if the line crosses the visible center, false otherwise.
|
|
@@ -277,6 +314,7 @@ export default class CanvasParticles {
|
|
|
277
314
|
* Precomputes and caches stroke style strings for a given base color and all possible alpha values (0–255).
|
|
278
315
|
* This is necessary because the rendering process involves up to [particles ** 2 / 2] lookups per frame.
|
|
279
316
|
*
|
|
317
|
+
* @private
|
|
280
318
|
* @param {string} color - The base color in the format `#rrggbb`.
|
|
281
319
|
* @returns {Object} - A lookup table mapping each alpha value (0–255) to its corresponding stroke style string in the format `#rrggbbaa`.
|
|
282
320
|
*
|
|
@@ -290,7 +328,7 @@ export default class CanvasParticles {
|
|
|
290
328
|
* hexadecimal alpha value (0x00–0xFF) to the base color.
|
|
291
329
|
* - The table is stored in `this.strokeStyleTable` for quick lookups.
|
|
292
330
|
*/
|
|
293
|
-
#generateStrokeStyleTable
|
|
331
|
+
#generateStrokeStyleTable(color) {
|
|
294
332
|
const table = {}
|
|
295
333
|
|
|
296
334
|
// Precompute stroke styles for alpha values 0–255
|
|
@@ -301,7 +339,12 @@ export default class CanvasParticles {
|
|
|
301
339
|
return table
|
|
302
340
|
}
|
|
303
341
|
|
|
304
|
-
|
|
342
|
+
/**
|
|
343
|
+
* Renders the particles on the canvas.
|
|
344
|
+
*
|
|
345
|
+
* @private
|
|
346
|
+
*/
|
|
347
|
+
#renderParticles() {
|
|
305
348
|
for (let particle of this.particles) {
|
|
306
349
|
if (particle.isVisible) {
|
|
307
350
|
// Draw the particle as a square if the size is smaller than 1 pixel (±183% faster than drawing only circles, using default settings)
|
|
@@ -321,8 +364,10 @@ export default class CanvasParticles {
|
|
|
321
364
|
|
|
322
365
|
/**
|
|
323
366
|
* Connects particles with lines if they are within the connection distance.
|
|
367
|
+
*
|
|
368
|
+
* @private
|
|
324
369
|
*/
|
|
325
|
-
#renderConnections
|
|
370
|
+
#renderConnections() {
|
|
326
371
|
const len = this.particleCount
|
|
327
372
|
const drawAll = this.options.particles.connectDist >= Math.min(this.canvas.width, this.canvas.height)
|
|
328
373
|
|
|
@@ -368,8 +413,10 @@ export default class CanvasParticles {
|
|
|
368
413
|
|
|
369
414
|
/**
|
|
370
415
|
* Clear the canvas and render the particles and their connections onto the canvas.
|
|
416
|
+
*
|
|
417
|
+
* @private
|
|
371
418
|
*/
|
|
372
|
-
#render
|
|
419
|
+
#render() {
|
|
373
420
|
this.canvas.width = this.canvas.width
|
|
374
421
|
this.ctx.fillStyle = this.options.particles.colorWithAlpha
|
|
375
422
|
this.ctx.lineWidth = 1
|
|
@@ -380,9 +427,11 @@ export default class CanvasParticles {
|
|
|
380
427
|
|
|
381
428
|
/**
|
|
382
429
|
* Main animation loop that updates and renders the particles.
|
|
383
|
-
* Runs recursively using
|
|
430
|
+
* Runs recursively using 'requestAnimationFrame'.
|
|
431
|
+
*
|
|
432
|
+
* @private
|
|
384
433
|
*/
|
|
385
|
-
#animation
|
|
434
|
+
#animation() {
|
|
386
435
|
if (!this.animating) return
|
|
387
436
|
|
|
388
437
|
requestAnimationFrame(() => this.#animation())
|
|
@@ -401,20 +450,26 @@ export default class CanvasParticles {
|
|
|
401
450
|
|
|
402
451
|
/**
|
|
403
452
|
* Starts the particle animation.
|
|
404
|
-
*
|
|
453
|
+
* Does nothing if already animating.
|
|
454
|
+
*
|
|
455
|
+
* @returns {CanvasParticles} - The current instance.
|
|
405
456
|
*/
|
|
406
|
-
start
|
|
407
|
-
if (this.animating)
|
|
408
|
-
|
|
409
|
-
|
|
457
|
+
start() {
|
|
458
|
+
if (!this.animating) {
|
|
459
|
+
this.animating = true
|
|
460
|
+
requestAnimationFrame(() => this.#animation())
|
|
461
|
+
}
|
|
462
|
+
return this
|
|
410
463
|
}
|
|
411
464
|
|
|
412
465
|
/**
|
|
413
466
|
* Stops the particle animation and clears the canvas.
|
|
414
467
|
*/
|
|
415
|
-
stop
|
|
468
|
+
stop(options) {
|
|
416
469
|
this.animating = false
|
|
417
|
-
this.canvas.width = this.canvas.width
|
|
470
|
+
if (options?.clear !== false) this.canvas.width = this.canvas.width
|
|
471
|
+
|
|
472
|
+
return true
|
|
418
473
|
}
|
|
419
474
|
|
|
420
475
|
/**
|
|
@@ -422,36 +477,40 @@ export default class CanvasParticles {
|
|
|
422
477
|
*/
|
|
423
478
|
|
|
424
479
|
/**
|
|
425
|
-
* Set and validate the options object
|
|
480
|
+
* Set and validate the options object.
|
|
426
481
|
* @param {Object} options - Object structure: https://github.com/Khoeckman/canvasParticles?tab=readme-ov-file#options
|
|
427
482
|
*/
|
|
428
|
-
setOptions
|
|
429
|
-
const
|
|
483
|
+
setOptions(options) {
|
|
484
|
+
const parse = this.#defaultIfNaN
|
|
430
485
|
|
|
431
486
|
// Format and store options
|
|
432
487
|
this.options = {
|
|
433
488
|
background: options.background ?? false,
|
|
434
|
-
framesPerUpdate:
|
|
435
|
-
|
|
489
|
+
framesPerUpdate: parse(Math.max(1, parseInt(options.framesPerUpdate)), 1),
|
|
490
|
+
animation: {
|
|
491
|
+
startOnEnter: !!(options.animation?.startOnEnter ?? true),
|
|
492
|
+
stopOnLeave: !!(options.animation?.stopOnLeave ?? true),
|
|
493
|
+
},
|
|
436
494
|
mouse: {
|
|
437
|
-
interactionType:
|
|
438
|
-
connectDistMult:
|
|
439
|
-
distRatio:
|
|
495
|
+
interactionType: parse(parseInt(options.mouse?.interactionType), 1),
|
|
496
|
+
connectDistMult: parse(options.mouse?.connectDistMult, 2 / 3),
|
|
497
|
+
distRatio: parse(options.mouse?.distRatio, 2 / 3),
|
|
440
498
|
},
|
|
441
499
|
particles: {
|
|
500
|
+
regenerateOnResize: !!options.particles?.regenerateOnResize,
|
|
442
501
|
color: options.particles?.color ?? 'black',
|
|
443
|
-
ppm:
|
|
444
|
-
max:
|
|
445
|
-
maxWork:
|
|
446
|
-
connectDist:
|
|
447
|
-
relSpeed:
|
|
448
|
-
relSize:
|
|
449
|
-
rotationSpeed:
|
|
502
|
+
ppm: parse(options.particles?.ppm, 100),
|
|
503
|
+
max: parse(options.particles?.max, 500),
|
|
504
|
+
maxWork: parse(Math.max(0, options.particles?.maxWork), Infinity),
|
|
505
|
+
connectDist: parse(Math.max(1, options.particles?.connectDistance), 150),
|
|
506
|
+
relSpeed: parse(Math.max(0, options.particles?.relSpeed), 1),
|
|
507
|
+
relSize: parse(Math.max(0, options.particles?.relSize), 1),
|
|
508
|
+
rotationSpeed: parse(Math.max(0, options.particles?.rotationSpeed / 100), 0.02),
|
|
450
509
|
},
|
|
451
510
|
gravity: {
|
|
452
|
-
repulsive:
|
|
453
|
-
pulling:
|
|
454
|
-
friction:
|
|
511
|
+
repulsive: parse(options.gravity?.repulsive, 0),
|
|
512
|
+
pulling: parse(options.gravity?.pulling, 0),
|
|
513
|
+
friction: parse(Math.max(0, Math.min(1, options.particles?.friction)), 0.8),
|
|
455
514
|
},
|
|
456
515
|
}
|
|
457
516
|
|
|
@@ -461,37 +520,38 @@ export default class CanvasParticles {
|
|
|
461
520
|
}
|
|
462
521
|
|
|
463
522
|
/**
|
|
464
|
-
* Set canvas background
|
|
523
|
+
* Set canvas background.
|
|
465
524
|
* @param {string} background - The style of the background. Can be any CSS supported background format.
|
|
466
525
|
*/
|
|
467
|
-
setBackground
|
|
468
|
-
if (typeof background === 'string') this.canvas.style.background = this.options.background = background
|
|
526
|
+
setBackground(background) {
|
|
527
|
+
if (typeof background === 'string') return (this.canvas.style.background = this.options.background = background), true
|
|
528
|
+
return false
|
|
469
529
|
}
|
|
470
530
|
|
|
471
531
|
/**
|
|
472
|
-
* Transform distance multiplier to absolute distance
|
|
532
|
+
* Transform distance multiplier to absolute distance.
|
|
473
533
|
* @param {float} connectDistMult - The maximum distance for the mouse to interact with the particles.
|
|
474
|
-
* The value is multiplied by particles.connectDistance
|
|
534
|
+
* The value is multiplied by 'particles.connectDistance'.
|
|
475
535
|
* @example 0.8 connectDistMult * 150 particles.connectDistance = 120 pixels
|
|
476
536
|
*/
|
|
477
|
-
setMouseConnectDistMult
|
|
478
|
-
this.options.mouse.connectDist = this.options.particles.connectDist * (
|
|
537
|
+
setMouseConnectDistMult(connectDistMult) {
|
|
538
|
+
this.options.mouse.connectDist = this.options.particles.connectDist * this.#defaultIfNaN(connectDistMult, 2 / 3)
|
|
479
539
|
}
|
|
480
540
|
|
|
481
541
|
/**
|
|
482
|
-
* Format particle color and opacity
|
|
542
|
+
* Format particle color and opacity.
|
|
483
543
|
* @param {string} color - The color of the particles and their connections. Can be any CSS supported color format.
|
|
484
544
|
*/
|
|
485
|
-
setParticleColor
|
|
545
|
+
setParticleColor(color) {
|
|
486
546
|
this.ctx.fillStyle = color
|
|
487
547
|
|
|
488
|
-
// Check if
|
|
548
|
+
// Check if 'ctx.fillStyle' is in hex format ("#RRGGBB" without alpha).
|
|
489
549
|
if (this.ctx.fillStyle[0] === '#') this.options.particles.opacity = 255
|
|
490
550
|
else {
|
|
491
|
-
// JavaScript's
|
|
551
|
+
// JavaScript's 'ctx.fillStyle' ensures the color will otherwise be in rgba format (e.g., "rgba(136, 244, 255, 0.25)")
|
|
492
552
|
|
|
493
553
|
// Extract the alpha value (0.25) from the rgba string, scale it to the range 0x00 to 0xff,
|
|
494
|
-
// and convert it to an integer. This value represents the opacity as a 2-character hex string
|
|
554
|
+
// and convert it to an integer. This value represents the opacity as a 2-character hex string
|
|
495
555
|
this.options.particles.opacity = ~~(this.ctx.fillStyle.split(',').at(-1).slice(1, -1) * 255)
|
|
496
556
|
|
|
497
557
|
// Example: extract 136, 244 and 255 from rgba(136, 244, 255, 0.25) and convert to hexadecimal '#rrggbb' format
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canvasparticles-js",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "In an HTML canvas, a bunch of interactive particles connected with lines when they approach each other.",
|
|
5
5
|
"main": "canvasParticles.js",
|
|
6
6
|
"module": "canvasParticles.mjs",
|