@basmilius/sparkle 2.0.0 → 2.1.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.
Files changed (148) hide show
  1. package/dist/index.d.mts +1192 -14
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +4552 -370
  4. package/dist/index.mjs.map +1 -1
  5. package/package.json +2 -1
  6. package/src/aurora/consts.ts +3 -0
  7. package/src/aurora/index.ts +4 -0
  8. package/src/aurora/layer.ts +152 -0
  9. package/src/aurora/simulation.ts +19 -0
  10. package/src/aurora/types.ts +13 -0
  11. package/src/balloons/consts.ts +3 -0
  12. package/src/balloons/index.ts +6 -0
  13. package/src/balloons/layer.ts +138 -0
  14. package/src/balloons/particle.ts +110 -0
  15. package/src/balloons/simulation.ts +19 -0
  16. package/src/balloons/types.ts +14 -0
  17. package/src/bubbles/consts.ts +3 -0
  18. package/src/bubbles/index.ts +4 -0
  19. package/src/bubbles/layer.ts +233 -0
  20. package/src/bubbles/simulation.ts +20 -0
  21. package/src/bubbles/types.ts +21 -0
  22. package/src/canvas.ts +20 -1
  23. package/src/color.ts +10 -0
  24. package/src/confetti/consts.ts +13 -13
  25. package/src/confetti/index.ts +6 -0
  26. package/src/confetti/layer.ts +152 -0
  27. package/src/confetti/particle.ts +105 -0
  28. package/src/confetti/shapes.ts +104 -0
  29. package/src/confetti/simulation.ts +9 -203
  30. package/src/confetti/types.ts +4 -1
  31. package/src/distance.ts +1 -1
  32. package/src/donuts/consts.ts +19 -0
  33. package/src/donuts/donut.ts +12 -0
  34. package/src/donuts/index.ts +3 -0
  35. package/src/donuts/layer.ts +270 -0
  36. package/src/donuts/simulation.ts +25 -0
  37. package/src/fireflies/consts.ts +3 -0
  38. package/src/fireflies/index.ts +6 -0
  39. package/src/fireflies/layer.ts +152 -0
  40. package/src/fireflies/particle.ts +124 -0
  41. package/src/fireflies/simulation.ts +18 -0
  42. package/src/fireflies/types.ts +17 -0
  43. package/src/firepit/consts.ts +3 -0
  44. package/src/firepit/index.ts +4 -0
  45. package/src/firepit/layer.ts +174 -0
  46. package/src/firepit/simulation.ts +17 -0
  47. package/src/firepit/types.ts +20 -0
  48. package/src/fireworks/explosion.ts +8 -8
  49. package/src/fireworks/firework.ts +9 -8
  50. package/src/fireworks/index.ts +6 -2
  51. package/src/fireworks/layer.ts +452 -0
  52. package/src/fireworks/simulation.ts +9 -484
  53. package/src/fireworks/spark.ts +7 -7
  54. package/src/glitter/consts.ts +13 -0
  55. package/src/glitter/index.ts +4 -0
  56. package/src/glitter/layer.ts +173 -0
  57. package/src/glitter/simulation.ts +19 -0
  58. package/src/glitter/types.ts +23 -0
  59. package/src/index.ts +28 -0
  60. package/src/lanterns/consts.ts +13 -0
  61. package/src/lanterns/index.ts +4 -0
  62. package/src/lanterns/layer.ts +166 -0
  63. package/src/lanterns/simulation.ts +17 -0
  64. package/src/lanterns/types.ts +14 -0
  65. package/src/layer.ts +24 -0
  66. package/src/layered.ts +185 -0
  67. package/src/leaves/consts.ts +16 -0
  68. package/src/leaves/index.ts +4 -0
  69. package/src/leaves/layer.ts +251 -0
  70. package/src/leaves/simulation.ts +18 -0
  71. package/src/leaves/types.ts +16 -0
  72. package/src/lightning/consts.ts +3 -0
  73. package/src/lightning/index.ts +6 -0
  74. package/src/lightning/layer.ts +41 -0
  75. package/src/lightning/simulation.ts +17 -0
  76. package/src/lightning/system.ts +196 -0
  77. package/src/lightning/types.ts +12 -0
  78. package/src/matrix/consts.ts +5 -0
  79. package/src/matrix/index.ts +4 -0
  80. package/src/matrix/layer.ts +146 -0
  81. package/src/matrix/simulation.ts +18 -0
  82. package/src/matrix/types.ts +8 -0
  83. package/src/orbits/consts.ts +13 -0
  84. package/src/orbits/index.ts +4 -0
  85. package/src/orbits/layer.ts +183 -0
  86. package/src/orbits/simulation.ts +19 -0
  87. package/src/orbits/types.ts +16 -0
  88. package/src/particles/consts.ts +3 -0
  89. package/src/particles/index.ts +4 -0
  90. package/src/particles/layer.ts +317 -0
  91. package/src/particles/simulation.ts +26 -0
  92. package/src/particles/types.ts +10 -0
  93. package/src/petals/consts.ts +13 -0
  94. package/src/petals/index.ts +4 -0
  95. package/src/petals/layer.ts +158 -0
  96. package/src/petals/simulation.ts +18 -0
  97. package/src/petals/types.ts +15 -0
  98. package/src/plasma/consts.ts +3 -0
  99. package/src/plasma/index.ts +4 -0
  100. package/src/plasma/layer.ts +92 -0
  101. package/src/plasma/simulation.ts +17 -0
  102. package/src/plasma/types.ts +5 -0
  103. package/src/rain/consts.ts +3 -0
  104. package/src/rain/index.ts +6 -0
  105. package/src/rain/layer.ts +172 -0
  106. package/src/rain/particle.ts +132 -0
  107. package/src/rain/simulation.ts +21 -0
  108. package/src/rain/types.ts +22 -0
  109. package/src/sandstorm/consts.ts +3 -0
  110. package/src/sandstorm/index.ts +4 -0
  111. package/src/sandstorm/layer.ts +135 -0
  112. package/src/sandstorm/simulation.ts +18 -0
  113. package/src/sandstorm/types.ts +10 -0
  114. package/src/shooting-stars/index.ts +3 -0
  115. package/src/shooting-stars/system.ts +149 -0
  116. package/src/shooting-stars/types.ts +10 -0
  117. package/src/simulation-canvas.ts +47 -0
  118. package/src/snow/consts.ts +2 -2
  119. package/src/snow/index.ts +1 -0
  120. package/src/snow/layer.ts +263 -0
  121. package/src/snow/simulation.ts +4 -288
  122. package/src/sparklers/consts.ts +3 -0
  123. package/src/sparklers/index.ts +6 -0
  124. package/src/sparklers/layer.ts +174 -0
  125. package/src/sparklers/particle.ts +89 -0
  126. package/src/sparklers/simulation.ts +30 -0
  127. package/src/sparklers/types.ts +13 -0
  128. package/src/stars/consts.ts +3 -0
  129. package/src/stars/index.ts +4 -0
  130. package/src/stars/layer.ts +133 -0
  131. package/src/stars/simulation.ts +22 -0
  132. package/src/stars/types.ts +12 -0
  133. package/src/streamers/consts.ts +14 -0
  134. package/src/streamers/index.ts +4 -0
  135. package/src/streamers/layer.ts +211 -0
  136. package/src/streamers/simulation.ts +16 -0
  137. package/src/streamers/types.ts +14 -0
  138. package/src/trail.ts +140 -0
  139. package/src/waves/consts.ts +3 -0
  140. package/src/waves/index.ts +4 -0
  141. package/src/waves/layer.ts +167 -0
  142. package/src/waves/simulation.ts +18 -0
  143. package/src/waves/types.ts +9 -0
  144. package/src/wormhole/consts.ts +3 -0
  145. package/src/wormhole/index.ts +4 -0
  146. package/src/wormhole/layer.ts +181 -0
  147. package/src/wormhole/simulation.ts +17 -0
  148. package/src/wormhole/types.ts +10 -0
@@ -0,0 +1,104 @@
1
+ import type { Shape } from './types';
2
+
3
+ const TWO_PI = Math.PI * 2;
4
+
5
+ export const SHAPE_PATHS: Record<Shape, Path2D> = {
6
+ bowtie: (() => {
7
+ const path = new Path2D();
8
+ path.moveTo(-1, -0.7);
9
+ path.lineTo(0, 0);
10
+ path.lineTo(-1, 0.7);
11
+ path.closePath();
12
+ path.moveTo(1, -0.7);
13
+ path.lineTo(0, 0);
14
+ path.lineTo(1, 0.7);
15
+ path.closePath();
16
+ return path;
17
+ })(),
18
+ circle: (() => {
19
+ const path = new Path2D();
20
+ path.ellipse(0, 0, 0.6, 1, 0, 0, TWO_PI);
21
+ return path;
22
+ })(),
23
+ crescent: (() => {
24
+ const path = new Path2D();
25
+ path.arc(0, 0, 1, 0, TWO_PI, false);
26
+ path.arc(0.45, 0, 0.9, 0, TWO_PI, true);
27
+ return path;
28
+ })(),
29
+ diamond: (() => {
30
+ const path = new Path2D();
31
+ path.moveTo(0, -1);
32
+ path.lineTo(0.6, 0);
33
+ path.lineTo(0, 1);
34
+ path.lineTo(-0.6, 0);
35
+ path.closePath();
36
+ return path;
37
+ })(),
38
+ heart: (() => {
39
+ const path = new Path2D();
40
+ path.moveTo(0, 1);
41
+ path.bezierCurveTo(-0.4, 0.55, -1, 0.1, -1, -0.35);
42
+ path.bezierCurveTo(-1, -0.8, -0.5, -1, 0, -0.6);
43
+ path.bezierCurveTo(0.5, -1, 1, -0.8, 1, -0.35);
44
+ path.bezierCurveTo(1, 0.1, 0.4, 0.55, 0, 1);
45
+ path.closePath();
46
+ return path;
47
+ })(),
48
+ hexagon: (() => {
49
+ const path = new Path2D();
50
+ for (let i = 0; i < 6; i++) {
51
+ const angle = (i * Math.PI / 3) - Math.PI / 2;
52
+ if (i === 0) {
53
+ path.moveTo(Math.cos(angle), Math.sin(angle));
54
+ } else {
55
+ path.lineTo(Math.cos(angle), Math.sin(angle));
56
+ }
57
+ }
58
+ path.closePath();
59
+ return path;
60
+ })(),
61
+ ribbon: (() => {
62
+ const path = new Path2D();
63
+ path.rect(-0.2, -1, 0.4, 2);
64
+ return path;
65
+ })(),
66
+ ring: (() => {
67
+ const path = new Path2D();
68
+ path.arc(0, 0, 1, 0, TWO_PI, false);
69
+ path.arc(0, 0, 0.55, 0, TWO_PI, true);
70
+ return path;
71
+ })(),
72
+ square: (() => {
73
+ const path = new Path2D();
74
+ path.rect(-0.7, -0.7, 1.4, 1.4);
75
+ return path;
76
+ })(),
77
+ star: (() => {
78
+ const path = new Path2D();
79
+ for (let i = 0; i < 10; i++) {
80
+ const r = i % 2 === 0 ? 1 : 0.42;
81
+ const angle = (i * Math.PI / 5) - Math.PI / 2;
82
+ if (i === 0) {
83
+ path.moveTo(r * Math.cos(angle), r * Math.sin(angle));
84
+ } else {
85
+ path.lineTo(r * Math.cos(angle), r * Math.sin(angle));
86
+ }
87
+ }
88
+ path.closePath();
89
+ return path;
90
+ })(),
91
+ triangle: (() => {
92
+ const path = new Path2D();
93
+ for (let i = 0; i < 3; i++) {
94
+ const angle = (i * 2 * Math.PI / 3) - Math.PI / 2;
95
+ if (i === 0) {
96
+ path.moveTo(Math.cos(angle), Math.sin(angle));
97
+ } else {
98
+ path.lineTo(Math.cos(angle), Math.sin(angle));
99
+ }
100
+ }
101
+ path.closePath();
102
+ return path;
103
+ })()
104
+ };
@@ -1,221 +1,27 @@
1
- import { hexToRGB } from '@basmilius/utils';
2
- import { LimitedFrameRateCanvas } from '../canvas';
3
- import { DEFAULT_CONFIG, MULBERRY } from './consts';
4
- import type { Config, Particle, ParticleConfig, Shape } from './types';
5
-
6
- const TWO_PI = Math.PI * 2;
7
-
8
- // Precomputed unit-size (size=1) Path2D objects per shape.
9
- // Size is encoded into the context transform, so each path is drawn once and reused every frame.
10
- const SHAPE_PATHS: Record<Shape, Path2D> = {
11
- circle: (() => {
12
- const path = new Path2D();
13
- path.ellipse(0, 0, 0.6, 1, 0, 0, TWO_PI);
14
- return path;
15
- })(),
16
- diamond: (() => {
17
- const path = new Path2D();
18
- path.moveTo(0, -1);
19
- path.lineTo(0.6, 0);
20
- path.lineTo(0, 1);
21
- path.lineTo(-0.6, 0);
22
- path.closePath();
23
- return path;
24
- })(),
25
- ribbon: (() => {
26
- const path = new Path2D();
27
- path.rect(-0.2, -1, 0.4, 2);
28
- return path;
29
- })(),
30
- square: (() => {
31
- const path = new Path2D();
32
- path.rect(-0.7, -0.7, 1.4, 1.4);
33
- return path;
34
- })(),
35
- star: (() => {
36
- const path = new Path2D();
37
- for (let i = 0; i < 10; i++) {
38
- const r = i % 2 === 0 ? 1 : 0.42;
39
- const angle = (i * Math.PI / 5) - Math.PI / 2;
40
- if (i === 0) path.moveTo(r * Math.cos(angle), r * Math.sin(angle));
41
- else path.lineTo(r * Math.cos(angle), r * Math.sin(angle));
42
- }
43
- path.closePath();
44
- return path;
45
- })(),
46
- triangle: (() => {
47
- const path = new Path2D();
48
- for (let i = 0; i < 3; i++) {
49
- const angle = (i * 2 * Math.PI / 3) - Math.PI / 2;
50
- if (i === 0) path.moveTo(Math.cos(angle), Math.sin(angle));
51
- else path.lineTo(Math.cos(angle), Math.sin(angle));
52
- }
53
- path.closePath();
54
- return path;
55
- })()
56
- };
1
+ import { SimulationCanvas } from '../simulation-canvas';
2
+ import { ConfettiLayer } from './layer';
3
+ import type { Config } from './types';
57
4
 
58
5
  export interface ConfettiSimulationConfig {
59
6
  readonly scale?: number;
60
7
  readonly canvasOptions?: CanvasRenderingContext2DSettings;
61
8
  }
62
9
 
63
- export class ConfettiSimulation extends LimitedFrameRateCanvas {
64
-
65
- readonly #scale: number;
66
- #particles: Particle[] = [];
10
+ export class ConfettiSimulation extends SimulationCanvas {
11
+ readonly #layer: ConfettiLayer;
67
12
 
68
13
  constructor(canvas: HTMLCanvasElement, config: ConfettiSimulationConfig = {}) {
69
- super(canvas, 60, config.canvasOptions ?? {colorSpace: 'display-p3'});
70
-
71
- this.#scale = config.scale ?? 1;
72
-
73
- this.canvas.style.position = 'absolute';
74
- this.canvas.style.top = '0';
75
- this.canvas.style.left = '0';
76
- this.canvas.style.height = '100%';
77
- this.canvas.style.width = '100%';
14
+ const layer = new ConfettiLayer(config);
15
+ super(canvas, layer, 60, config.canvasOptions ?? {colorSpace: 'display-p3'});
16
+ this.#layer = layer;
78
17
  }
79
18
 
80
19
  fire(config: Partial<Config>): void {
81
20
  this.onResize();
82
- this.draw();
83
-
84
- const resolved = { ...DEFAULT_CONFIG, ...config };
85
- const { angle, colors, decay, gravity, shapes, spread, startVelocity, ticks, x, y } = resolved;
86
- const numberOfParticles = Math.max(1, resolved.particles);
87
-
88
- for (let i = 0; i < numberOfParticles; i++) {
89
- const particle = this.#createParticle({
90
- angle,
91
- color: hexToRGB(colors[Math.floor(MULBERRY.next() * colors.length)]),
92
- decay,
93
- gravity: gravity * this.#scale,
94
- shape: shapes[Math.floor(MULBERRY.next() * shapes.length)],
95
- spread,
96
- startVelocity: startVelocity * this.#scale,
97
- ticks,
98
- x: this.width * x,
99
- y: this.height * y
100
- });
101
-
102
- this.#tickParticle(particle);
103
- this.#particles.push(particle);
104
- }
21
+ this.#layer.fire(config);
105
22
 
106
23
  if (!this.isTicking) {
107
24
  this.start();
108
25
  }
109
26
  }
110
-
111
- draw(): void {
112
- const { context, width, height } = this;
113
- context.clearRect(0, 0, width, height);
114
-
115
- const particles = this.#particles;
116
-
117
- for (let i = 0; i < particles.length; i++) {
118
- const p = particles[i];
119
- const flipCos = Math.cos(p.flipAngle);
120
- const size = p.size;
121
-
122
- // Encode translate + rotate + scale(flipCos, 1) + scale(size, size) in a single setTransform call,
123
- // avoiding save()/translate()/rotate()/scale()/restore() — 5 API calls replaced by 1.
124
- context.setTransform(
125
- p.rotCos * flipCos * size,
126
- p.rotSin * flipCos * size,
127
- -p.rotSin * size,
128
- p.rotCos * size,
129
- p.x,
130
- p.y
131
- );
132
- context.globalAlpha = 1 - p.tick / p.totalTicks;
133
- context.fillStyle = p.colorStr;
134
- context.fill(SHAPE_PATHS[p.shape]);
135
- }
136
-
137
- context.resetTransform();
138
- }
139
-
140
- tick(): void {
141
- const particles = this.#particles;
142
- let alive = 0;
143
-
144
- // Normalize to 60fps-equivalent units so physics is frame-rate independent.
145
- // dt ≈ 1.0 at 60fps, 0.5 at 120fps, 2.0 at 30fps.
146
- // Cap at 200ms to avoid large jumps after tab switches or dropped frames.
147
- const dt = this.delta > 0 && this.delta < 200 ? this.delta / (1000 / 60) : 1;
148
-
149
- // Single pass: tick live particles and compact the array in-place.
150
- // Avoids filter() allocation and a separate forEach pass.
151
- for (let i = 0; i < particles.length; i++) {
152
- const p = particles[i];
153
-
154
- if (p.tick < p.totalTicks) {
155
- this.#tickParticle(p, dt);
156
- particles[alive++] = p;
157
- }
158
- }
159
-
160
- particles.length = alive;
161
-
162
- if (alive === 0) {
163
- this.stop();
164
- }
165
- }
166
-
167
- onResize(): void {
168
- super.onResize();
169
- this.canvas.width = this.width;
170
- this.canvas.height = this.height;
171
- }
172
-
173
- #createParticle(config: ParticleConfig): Particle {
174
- const launchAngle = -(config.angle * Math.PI / 180)
175
- + (0.5 * config.spread * Math.PI / 180)
176
- - (MULBERRY.next() * config.spread * Math.PI / 180);
177
-
178
- const speed = config.startVelocity * (0.5 + MULBERRY.next());
179
- const rotAngle = MULBERRY.next() * TWO_PI;
180
-
181
- return {
182
- colorStr: `rgb(${config.color[0]}, ${config.color[1]}, ${config.color[2]})`,
183
- decay: config.decay - 0.05 + MULBERRY.next() * 0.1,
184
- flipAngle: MULBERRY.next() * TWO_PI,
185
- flipSpeed: 0.03 + MULBERRY.next() * 0.05,
186
- gravity: config.gravity,
187
- rotAngle,
188
- rotCos: Math.cos(rotAngle),
189
- rotSin: Math.sin(rotAngle),
190
- rotSpeed: (MULBERRY.next() - 0.5) * 0.06,
191
- shape: config.shape,
192
- size: (5 + MULBERRY.next() * 5) * this.#scale,
193
- swing: MULBERRY.next() * TWO_PI,
194
- swingAmp: 0.5 + MULBERRY.next() * 1.5,
195
- swingSpeed: 0.025 + MULBERRY.next() * 0.035,
196
- tick: 0,
197
- totalTicks: config.ticks,
198
- vx: Math.cos(launchAngle) * speed,
199
- vy: Math.sin(launchAngle) * speed,
200
- x: config.x,
201
- y: config.y
202
- };
203
- }
204
-
205
- // dt defaults to 1 (60fps-equivalent) for the initial kick in fire() before the loop starts.
206
- #tickParticle(particle: Particle, dt: number = 1): void {
207
- const decayFactor = Math.pow(particle.decay, dt);
208
- particle.vx *= decayFactor;
209
- particle.vy *= decayFactor;
210
- particle.vy += particle.gravity * 0.35 * dt;
211
- particle.swing += particle.swingSpeed * dt;
212
- particle.x += (particle.vx + particle.swingAmp * Math.cos(particle.swing)) * dt;
213
- particle.y += particle.vy * dt;
214
- particle.rotAngle += particle.rotSpeed * dt;
215
- particle.rotCos = Math.cos(particle.rotAngle);
216
- particle.rotSin = Math.sin(particle.rotAngle);
217
- particle.flipAngle += particle.flipSpeed * dt;
218
- particle.tick += dt;
219
- }
220
-
221
27
  }
@@ -3,6 +3,7 @@ export type Config = {
3
3
  readonly colors: string[];
4
4
  readonly decay: number;
5
5
  readonly gravity: number;
6
+ readonly palette: Palette;
6
7
  readonly particles: number;
7
8
  readonly shapes: Shape[];
8
9
  readonly spread: number;
@@ -48,6 +49,8 @@ export type ParticleConfig = {
48
49
  readonly y: number;
49
50
  };
50
51
 
52
+ export type Palette = 'classic' | 'pastel' | 'vibrant' | 'warm';
53
+
51
54
  export type RGB = [r: number, g: number, b: number];
52
55
 
53
- export type Shape = 'circle' | 'diamond' | 'ribbon' | 'square' | 'star' | 'triangle';
56
+ export type Shape = 'bowtie' | 'circle' | 'crescent' | 'diamond' | 'heart' | 'hexagon' | 'ribbon' | 'ring' | 'square' | 'star' | 'triangle';
package/src/distance.ts CHANGED
@@ -4,5 +4,5 @@ export function distance(a: Point, b: Point): number {
4
4
  let x = a.x - b.x;
5
5
  let y = a.y - b.y;
6
6
 
7
- return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2));
7
+ return Math.sqrt(x * x + y * y);
8
8
  }
@@ -0,0 +1,19 @@
1
+ import { type Mulberry32, mulberry32 } from '@basmilius/utils';
2
+ import type { DonutSimulationConfig } from './simulation';
3
+
4
+ export const MULBERRY: Mulberry32 = mulberry32(13);
5
+
6
+ export const DEFAULT_CONFIG: DonutSimulationConfig = {
7
+ background: '#a51955',
8
+ collisionPadding: 20,
9
+ colors: ['#bd1961', '#da287c'],
10
+ count: 12,
11
+ mouseAvoidance: false,
12
+ mouseAvoidanceRadius: 150,
13
+ mouseAvoidanceStrength: 0.03,
14
+ radiusRange: [60, 90],
15
+ repulsionStrength: 0.02,
16
+ rotationSpeedRange: [0.0005, 0.002],
17
+ speedRange: [0.15, 0.6],
18
+ thickness: 0.39
19
+ };
@@ -0,0 +1,12 @@
1
+ export type Donut = {
2
+ outerRadius: number;
3
+ innerRadius: number;
4
+ x: number;
5
+ y: number;
6
+ angle: number;
7
+ speed: number;
8
+ rotationSpeed: number;
9
+ color: string;
10
+ vx: number;
11
+ vy: number;
12
+ };
@@ -0,0 +1,3 @@
1
+ export { DonutLayer } from './layer';
2
+ export { DonutSimulation } from './simulation';
3
+ export type { DonutSimulationConfig } from './simulation';
@@ -0,0 +1,270 @@
1
+ import { SimulationLayer } from '../layer';
2
+ import { DEFAULT_CONFIG, MULBERRY } from './consts';
3
+ import type { Donut } from './donut';
4
+ import type { DonutSimulationConfig } from './simulation';
5
+
6
+ export class DonutLayer extends SimulationLayer {
7
+ readonly #background: string;
8
+ readonly #collisionPadding: number;
9
+ readonly #colors: string[];
10
+ readonly #count: number;
11
+ readonly #mouseAvoidance: boolean;
12
+ readonly #mouseAvoidanceRadius: number;
13
+ readonly #mouseAvoidanceStrength: number;
14
+ readonly #radiusRange: [number, number];
15
+ readonly #repulsionStrength: number;
16
+ readonly #rotationSpeedRange: [number, number];
17
+ readonly #scale: number;
18
+ readonly #speedRange: [number, number];
19
+ readonly #thickness: number;
20
+ readonly #onMouseMoveBound: (event: MouseEvent) => void;
21
+ readonly #onMouseLeaveBound: () => void;
22
+ #donuts: Donut[] = [];
23
+ #mouseX: number = -1;
24
+ #mouseY: number = -1;
25
+ #mouseOnCanvas: boolean = false;
26
+ #width: number = 960;
27
+ #height: number = 540;
28
+ #initialized: boolean = false;
29
+
30
+ constructor(config: DonutSimulationConfig = {}) {
31
+ super();
32
+
33
+ const scale = config.scale ?? 1;
34
+
35
+ this.#background = config.background ?? DEFAULT_CONFIG.background!;
36
+ this.#collisionPadding = (config.collisionPadding ?? DEFAULT_CONFIG.collisionPadding!) * scale;
37
+ this.#colors = config.colors ?? DEFAULT_CONFIG.colors!;
38
+ this.#count = config.count ?? DEFAULT_CONFIG.count!;
39
+ this.#mouseAvoidance = config.mouseAvoidance ?? DEFAULT_CONFIG.mouseAvoidance!;
40
+ this.#mouseAvoidanceRadius = (config.mouseAvoidanceRadius ?? DEFAULT_CONFIG.mouseAvoidanceRadius!) * scale;
41
+ this.#mouseAvoidanceStrength = config.mouseAvoidanceStrength ?? DEFAULT_CONFIG.mouseAvoidanceStrength!;
42
+ this.#radiusRange = [
43
+ (config.radiusRange ?? DEFAULT_CONFIG.radiusRange!)[0] * scale,
44
+ (config.radiusRange ?? DEFAULT_CONFIG.radiusRange!)[1] * scale
45
+ ];
46
+ this.#repulsionStrength = config.repulsionStrength ?? DEFAULT_CONFIG.repulsionStrength!;
47
+ this.#rotationSpeedRange = config.rotationSpeedRange ?? DEFAULT_CONFIG.rotationSpeedRange!;
48
+ this.#scale = scale;
49
+ this.#speedRange = [
50
+ (config.speedRange ?? DEFAULT_CONFIG.speedRange!)[0] * scale,
51
+ (config.speedRange ?? DEFAULT_CONFIG.speedRange!)[1] * scale
52
+ ];
53
+ this.#thickness = config.thickness ?? DEFAULT_CONFIG.thickness!;
54
+
55
+ this.#onMouseMoveBound = (event: MouseEvent) => this.#onMouseMove(event);
56
+ this.#onMouseLeaveBound = () => this.#onMouseLeave();
57
+ }
58
+
59
+ onResize(width: number, height: number): void {
60
+ this.#width = width;
61
+ this.#height = height;
62
+
63
+ if (!this.#initialized) {
64
+ this.#initialized = true;
65
+ this.#donuts = [];
66
+
67
+ for (let i = 0; i < this.#count; i++) {
68
+ this.#donuts.push(this.#createNonOverlapping());
69
+ }
70
+ }
71
+ }
72
+
73
+ onMount(canvas: HTMLCanvasElement): void {
74
+ if (this.#mouseAvoidance) {
75
+ canvas.addEventListener('mousemove', this.#onMouseMoveBound, {passive: true});
76
+ canvas.addEventListener('mouseleave', this.#onMouseLeaveBound, {passive: true});
77
+ }
78
+ }
79
+
80
+ onUnmount(canvas: HTMLCanvasElement): void {
81
+ canvas.removeEventListener('mousemove', this.#onMouseMoveBound);
82
+ canvas.removeEventListener('mouseleave', this.#onMouseLeaveBound);
83
+ }
84
+
85
+ tick(dt: number, width: number, height: number): void {
86
+ this.#width = width;
87
+ this.#height = height;
88
+
89
+ this.#resolveCollisions(dt);
90
+
91
+ if (this.#mouseAvoidance && this.#mouseOnCanvas) {
92
+ this.#resolveMouseAvoidance(dt);
93
+ }
94
+
95
+ for (const donut of this.#donuts) {
96
+ this.#updateDonut(donut, dt);
97
+ }
98
+ }
99
+
100
+ draw(ctx: CanvasRenderingContext2D, width: number, height: number): void {
101
+ ctx.globalAlpha = 1;
102
+ ctx.fillStyle = this.#background;
103
+ ctx.fillRect(0, 0, width, height);
104
+
105
+ for (const donut of this.#donuts) {
106
+ ctx.save();
107
+ ctx.translate(donut.x, donut.y);
108
+ ctx.rotate(donut.angle);
109
+
110
+ ctx.beginPath();
111
+ ctx.arc(0, 0, donut.outerRadius, 0, Math.PI * 2);
112
+ ctx.arc(0, 0, donut.innerRadius, 0, Math.PI * 2, true);
113
+ ctx.closePath();
114
+
115
+ ctx.fillStyle = donut.color;
116
+ ctx.fill();
117
+ ctx.restore();
118
+ }
119
+ }
120
+
121
+ #updateDonut(donut: Donut, dt: number): void {
122
+ const currentSpeed = Math.sqrt(donut.vx * donut.vx + donut.vy * donut.vy);
123
+
124
+ if (currentSpeed > donut.speed) {
125
+ const damping = Math.pow(0.995, dt);
126
+ donut.vx *= damping;
127
+ donut.vy *= damping;
128
+ }
129
+
130
+ donut.x += donut.vx * dt;
131
+ donut.y += donut.vy * dt;
132
+ donut.angle += donut.rotationSpeed * dt;
133
+
134
+ const limit = donut.outerRadius * 0.5;
135
+ const width = this.#width;
136
+ const height = this.#height;
137
+
138
+ if (donut.x < -limit) {
139
+ donut.x = -limit;
140
+ donut.vx = Math.abs(donut.vx);
141
+ }
142
+
143
+ if (donut.x > width + limit) {
144
+ donut.x = width + limit;
145
+ donut.vx = -Math.abs(donut.vx);
146
+ }
147
+
148
+ if (donut.y < -limit) {
149
+ donut.y = -limit;
150
+ donut.vy = Math.abs(donut.vy);
151
+ }
152
+
153
+ if (donut.y > height + limit) {
154
+ donut.y = height + limit;
155
+ donut.vy = -Math.abs(donut.vy);
156
+ }
157
+ }
158
+
159
+ #onMouseMove(event: MouseEvent): void {
160
+ const target = event.currentTarget as HTMLCanvasElement;
161
+ const rect = target.getBoundingClientRect();
162
+ this.#mouseX = event.clientX - rect.left;
163
+ this.#mouseY = event.clientY - rect.top;
164
+ this.#mouseOnCanvas = true;
165
+ }
166
+
167
+ #onMouseLeave(): void {
168
+ this.#mouseOnCanvas = false;
169
+ }
170
+
171
+ #resolveMouseAvoidance(dt: number): void {
172
+ const radius = this.#mouseAvoidanceRadius;
173
+ const strength = this.#mouseAvoidanceStrength;
174
+ const mx = this.#mouseX;
175
+ const my = this.#mouseY;
176
+
177
+ for (const donut of this.#donuts) {
178
+ const dx = donut.x - mx;
179
+ const dy = donut.y - my;
180
+ const dist = Math.sqrt(dx * dx + dy * dy);
181
+ const minDist = donut.outerRadius + radius;
182
+
183
+ if (dist < minDist && dist > 0) {
184
+ const overlap = minDist - dist;
185
+ const nx = dx / dist;
186
+ const ny = dy / dist;
187
+ const force = overlap * strength * dt;
188
+
189
+ donut.vx += nx * force;
190
+ donut.vy += ny * force;
191
+ }
192
+ }
193
+ }
194
+
195
+ #resolveCollisions(dt: number): void {
196
+ const padding = this.#collisionPadding;
197
+ const strength = this.#repulsionStrength;
198
+
199
+ for (let i = 0; i < this.#donuts.length; i++) {
200
+ for (let j = i + 1; j < this.#donuts.length; j++) {
201
+ const a = this.#donuts[i];
202
+ const b = this.#donuts[j];
203
+ const dx = b.x - a.x;
204
+ const dy = b.y - a.y;
205
+ const dist = Math.sqrt(dx * dx + dy * dy);
206
+ const minDist = a.outerRadius + b.outerRadius + padding;
207
+
208
+ if (dist < minDist && dist > 0) {
209
+ const overlap = minDist - dist;
210
+ const nx = dx / dist;
211
+ const ny = dy / dist;
212
+ const force = overlap * strength * dt;
213
+
214
+ a.vx -= nx * force;
215
+ a.vy -= ny * force;
216
+ b.vx += nx * force;
217
+ b.vy += ny * force;
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ #createDonut(): Donut {
224
+ const outerRadius = this.#rand(this.#radiusRange[0], this.#radiusRange[1]);
225
+ const innerRadius = outerRadius * (1 - this.#thickness);
226
+ const speed = this.#rand(this.#speedRange[0], this.#speedRange[1]);
227
+ const direction = MULBERRY.next() * Math.PI * 2;
228
+
229
+ return {
230
+ outerRadius,
231
+ innerRadius,
232
+ x: this.#rand(-outerRadius, this.#width + outerRadius),
233
+ y: this.#rand(-outerRadius, this.#height + outerRadius),
234
+ angle: MULBERRY.next() * Math.PI * 2,
235
+ speed,
236
+ rotationSpeed: this.#rand(this.#rotationSpeedRange[0], this.#rotationSpeedRange[1]) * (MULBERRY.next() > 0.5 ? 1 : -1),
237
+ color: this.#colors[Math.floor(MULBERRY.next() * this.#colors.length)],
238
+ vx: Math.cos(direction) * speed,
239
+ vy: Math.sin(direction) * speed
240
+ };
241
+ }
242
+
243
+ #createNonOverlapping(maxAttempts: number = 200): Donut {
244
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
245
+ const donut = this.#createDonut();
246
+
247
+ if (!this.#overlapsAny(donut)) {
248
+ return donut;
249
+ }
250
+ }
251
+
252
+ return this.#createDonut();
253
+ }
254
+
255
+ #overlapsAny(donut: Donut): boolean {
256
+ const minDist = this.#collisionPadding;
257
+
258
+ return this.#donuts.some((other) => {
259
+ const dx = donut.x - other.x;
260
+ const dy = donut.y - other.y;
261
+ const dist = Math.sqrt(dx * dx + dy * dy);
262
+
263
+ return dist < donut.outerRadius + other.outerRadius + minDist;
264
+ });
265
+ }
266
+
267
+ #rand(min: number, max: number): number {
268
+ return MULBERRY.next() * (max - min) + min;
269
+ }
270
+ }
@@ -0,0 +1,25 @@
1
+ import { SimulationCanvas } from '../simulation-canvas';
2
+ import { DonutLayer } from './layer';
3
+
4
+ export interface DonutSimulationConfig {
5
+ readonly background?: string;
6
+ readonly canvasOptions?: CanvasRenderingContext2DSettings;
7
+ readonly collisionPadding?: number;
8
+ readonly colors?: string[];
9
+ readonly count?: number;
10
+ readonly mouseAvoidance?: boolean;
11
+ readonly mouseAvoidanceRadius?: number;
12
+ readonly mouseAvoidanceStrength?: number;
13
+ readonly radiusRange?: [number, number];
14
+ readonly repulsionStrength?: number;
15
+ readonly rotationSpeedRange?: [number, number];
16
+ readonly scale?: number;
17
+ readonly speedRange?: [number, number];
18
+ readonly thickness?: number;
19
+ }
20
+
21
+ export class DonutSimulation extends SimulationCanvas {
22
+ constructor(canvas: HTMLCanvasElement, config: DonutSimulationConfig = {}) {
23
+ super(canvas, new DonutLayer(config), 60, config.canvasOptions ?? {colorSpace: 'display-p3'});
24
+ }
25
+ }