@basmilius/sparkle 2.1.0 → 2.3.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 (108) hide show
  1. package/dist/index.d.mts +317 -459
  2. package/dist/index.d.mts.map +1 -1
  3. package/dist/index.mjs +1258 -949
  4. package/dist/index.mjs.map +1 -1
  5. package/package.json +6 -2
  6. package/src/aurora/index.ts +9 -3
  7. package/src/aurora/layer.ts +57 -29
  8. package/src/balloons/index.ts +9 -3
  9. package/src/balloons/layer.ts +50 -19
  10. package/src/bubbles/index.ts +9 -3
  11. package/src/bubbles/layer.ts +30 -17
  12. package/src/canvas.ts +92 -2
  13. package/src/color.ts +11 -2
  14. package/src/confetti/index.ts +15 -3
  15. package/src/confetti/layer.ts +8 -5
  16. package/src/confetti/particle.ts +12 -11
  17. package/src/confetti/shapes.ts +84 -97
  18. package/src/donuts/consts.ts +2 -2
  19. package/src/donuts/index.ts +9 -3
  20. package/src/donuts/layer.ts +43 -12
  21. package/src/effect.ts +107 -0
  22. package/src/fade.ts +87 -0
  23. package/src/fireflies/index.ts +9 -3
  24. package/src/fireflies/layer.ts +26 -9
  25. package/src/fireflies/particle.ts +2 -2
  26. package/src/firepit/index.ts +9 -3
  27. package/src/firepit/layer.ts +26 -7
  28. package/src/fireworks/create-explosion.ts +237 -0
  29. package/src/fireworks/explosion.ts +1 -1
  30. package/src/fireworks/index.ts +15 -3
  31. package/src/fireworks/layer.ts +55 -304
  32. package/src/fireworks/spark.ts +2 -2
  33. package/src/fireworks/types.ts +2 -2
  34. package/src/glitter/index.ts +9 -4
  35. package/src/glitter/layer.ts +15 -7
  36. package/src/glitter/types.ts +10 -0
  37. package/src/index.ts +3 -4
  38. package/src/lanterns/index.ts +9 -4
  39. package/src/lanterns/layer.ts +22 -10
  40. package/src/lanterns/types.ts +8 -0
  41. package/src/layer.ts +13 -11
  42. package/src/leaves/index.ts +9 -4
  43. package/src/leaves/layer.ts +21 -14
  44. package/src/leaves/types.ts +9 -0
  45. package/src/lightning/index.ts +9 -4
  46. package/src/lightning/layer.ts +4 -4
  47. package/src/lightning/system.ts +3 -3
  48. package/src/lightning/types.ts +10 -2
  49. package/src/matrix/index.ts +9 -4
  50. package/src/matrix/layer.ts +15 -7
  51. package/src/matrix/types.ts +9 -0
  52. package/src/orbits/index.ts +9 -4
  53. package/src/orbits/layer.ts +51 -21
  54. package/src/orbits/types.ts +12 -1
  55. package/src/particles/index.ts +9 -3
  56. package/src/particles/layer.ts +55 -12
  57. package/src/petals/index.ts +9 -3
  58. package/src/petals/layer.ts +29 -13
  59. package/src/plasma/index.ts +9 -3
  60. package/src/plasma/layer.ts +21 -6
  61. package/src/rain/index.ts +9 -3
  62. package/src/rain/layer.ts +30 -8
  63. package/src/sandstorm/index.ts +9 -3
  64. package/src/sandstorm/layer.ts +26 -9
  65. package/src/scene.ts +204 -0
  66. package/src/shooting-stars/system.ts +26 -24
  67. package/src/shooting-stars/types.ts +2 -1
  68. package/src/simulation-canvas.ts +45 -6
  69. package/src/snow/index.ts +9 -3
  70. package/src/snow/layer.ts +24 -11
  71. package/src/sparklers/index.ts +13 -3
  72. package/src/sparklers/layer.ts +61 -15
  73. package/src/stars/index.ts +9 -3
  74. package/src/stars/layer.ts +28 -22
  75. package/src/streamers/index.ts +9 -3
  76. package/src/streamers/layer.ts +18 -6
  77. package/src/streamers/types.ts +1 -1
  78. package/src/waves/index.ts +9 -3
  79. package/src/waves/layer.ts +42 -45
  80. package/src/waves/types.ts +1 -0
  81. package/src/wormhole/index.ts +9 -3
  82. package/src/wormhole/layer.ts +22 -6
  83. package/src/aurora/simulation.ts +0 -19
  84. package/src/balloons/simulation.ts +0 -19
  85. package/src/bubbles/simulation.ts +0 -20
  86. package/src/confetti/simulation.ts +0 -27
  87. package/src/donuts/simulation.ts +0 -25
  88. package/src/fireflies/simulation.ts +0 -18
  89. package/src/firepit/simulation.ts +0 -17
  90. package/src/fireworks/simulation.ts +0 -18
  91. package/src/glitter/simulation.ts +0 -19
  92. package/src/lanterns/simulation.ts +0 -17
  93. package/src/layered.ts +0 -185
  94. package/src/leaves/simulation.ts +0 -18
  95. package/src/lightning/simulation.ts +0 -17
  96. package/src/matrix/simulation.ts +0 -18
  97. package/src/orbits/simulation.ts +0 -19
  98. package/src/particles/simulation.ts +0 -26
  99. package/src/petals/simulation.ts +0 -18
  100. package/src/plasma/simulation.ts +0 -17
  101. package/src/rain/simulation.ts +0 -21
  102. package/src/sandstorm/simulation.ts +0 -18
  103. package/src/snow/simulation.ts +0 -17
  104. package/src/sparklers/simulation.ts +0 -30
  105. package/src/stars/simulation.ts +0 -22
  106. package/src/streamers/simulation.ts +0 -16
  107. package/src/waves/simulation.ts +0 -18
  108. package/src/wormhole/simulation.ts +0 -17
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@basmilius/sparkle",
3
3
  "license": "MIT",
4
- "version": "2.1.0",
4
+ "version": "2.3.0",
5
5
  "author": {
6
6
  "email": "bas@mili.us",
7
7
  "name": "Bas Milius",
8
8
  "url": "https://bas.dev"
9
9
  },
10
+ "homepage": "https://sparkle.graphics",
10
11
  "repository": {
11
12
  "type": "git",
12
13
  "url": "https://github.com/basmilius/sparkle.git"
@@ -44,11 +45,14 @@
44
45
  },
45
46
  "devDependencies": {
46
47
  "@types/node": "^25.5.0",
48
+ "@vitejs/plugin-vue": "^5.2.1",
47
49
  "tsdown": "^0.21.4",
48
50
  "typescript": "^6.0.2",
49
51
  "vite": "^8.0.3",
50
52
  "vitepress": "^1.6.3",
51
53
  "vitepress-plugin-example": "^1.4.0",
52
- "vitepress-plugin-render": "^1.4.0"
54
+ "vitepress-plugin-render": "^1.4.0",
55
+ "vue": "^3.5.13",
56
+ "vue-router": "^4.5.0"
53
57
  }
54
58
  }
@@ -1,4 +1,10 @@
1
- export { AuroraLayer } from './layer';
2
- export { AuroraSimulation } from './simulation';
3
- export type { AuroraSimulationConfig } from './simulation';
1
+ import { Aurora } from './layer';
2
+ import type { AuroraConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createAurora(config?: AuroraConfig): Effect<AuroraConfig> {
6
+ return new Aurora(config);
7
+ }
8
+
9
+ export type { AuroraConfig };
4
10
  export type { AuroraBand } from './types';
@@ -1,21 +1,29 @@
1
1
  import { hexToRGB } from '@basmilius/utils';
2
- import { SimulationLayer } from '../layer';
2
+ import { Effect } from '../effect';
3
3
  import { MULBERRY } from './consts';
4
- import type { AuroraSimulationConfig } from './simulation';
5
4
  import type { AuroraBand } from './types';
6
5
 
7
6
  const DEFAULT_COLORS = ['#9922ff', '#4455ff', '#0077ee', '#00aabb', '#22ddff'];
8
7
  const TOP_HUE = 265;
9
8
 
10
- export class AuroraLayer extends SimulationLayer {
11
- readonly #speed: number;
12
- readonly #intensity: number;
13
- readonly #waveAmplitude: number;
14
- readonly #verticalPosition: number;
15
- #time: number = 0;
9
+ export interface AuroraConfig {
10
+ readonly bands?: number;
11
+ readonly colors?: string[];
12
+ readonly speed?: number;
13
+ readonly intensity?: number;
14
+ readonly waveAmplitude?: number;
15
+ readonly verticalPosition?: number;
16
+ readonly scale?: number;
17
+ }
18
+
19
+ export class Aurora extends Effect<AuroraConfig> {
20
+ #speed: number;
21
+ #intensity: number;
22
+ #waveAmplitude: number;
23
+ #verticalPosition: number;
16
24
  #bands: AuroraBand[] = [];
17
25
 
18
- constructor(config: AuroraSimulationConfig = {}) {
26
+ constructor(config: AuroraConfig = {}) {
19
27
  super();
20
28
 
21
29
  const bandCount = config.bands ?? 5;
@@ -36,26 +44,39 @@ export class AuroraLayer extends SimulationLayer {
36
44
 
37
45
  this.#bands.push({
38
46
  x: cluster + (MULBERRY.next() - 0.5) * 0.22,
39
- baseY: this.#verticalPosition + (MULBERRY.next() - 0.5) * 0.08,
47
+ baseY: (MULBERRY.next() - 0.5) * 0.08,
40
48
  height: 0.5 + MULBERRY.next() * 0.3,
41
49
  sigma: 160 + MULBERRY.next() * 110,
42
50
  phase1: MULBERRY.next() * Math.PI * 2,
43
51
  phase2: MULBERRY.next() * Math.PI * 2,
44
52
  amplitude1: 0.015 + MULBERRY.next() * 0.025,
45
53
  frequency1: 0.003 + MULBERRY.next() * 0.004,
46
- speed: (0.4 + MULBERRY.next() * 0.6) * this.#speed,
54
+ speed: 0.4 + MULBERRY.next() * 0.6,
47
55
  hue,
48
- opacity: (0.5 + MULBERRY.next() * 0.3) * this.#intensity
56
+ opacity: 0.5 + MULBERRY.next() * 0.3
49
57
  });
50
58
  }
51
59
  }
52
60
 
53
- tick(dt: number, width: number, height: number): void {
54
- this.#time += 0.016 * dt * this.#speed;
61
+ configure(config: Partial<AuroraConfig>): void {
62
+ if (config.speed !== undefined) {
63
+ this.#speed = config.speed;
64
+ }
65
+ if (config.intensity !== undefined) {
66
+ this.#intensity = config.intensity;
67
+ }
68
+ if (config.waveAmplitude !== undefined) {
69
+ this.#waveAmplitude = config.waveAmplitude;
70
+ }
71
+ if (config.verticalPosition !== undefined) {
72
+ this.#verticalPosition = config.verticalPosition;
73
+ }
74
+ }
55
75
 
76
+ tick(dt: number, _width: number, _height: number): void {
56
77
  for (const band of this.#bands) {
57
- band.phase1 += 0.005 * band.speed * dt;
58
- band.phase2 += 0.008 * band.speed * dt;
78
+ band.phase1 += 0.005 * band.speed * this.#speed * dt;
79
+ band.phase2 += 0.008 * band.speed * this.#speed * dt;
59
80
  }
60
81
  }
61
82
 
@@ -77,7 +98,7 @@ export class AuroraLayer extends SimulationLayer {
77
98
  for (const band of this.#bands) {
78
99
  const swayX = band.amplitude1 * width * Math.sin(band.phase1);
79
100
  const cx = band.x * width + swayX;
80
- const baseY = band.baseY * height;
101
+ const baseY = (this.#verticalPosition + band.baseY) * height;
81
102
  const rayHeight = band.height * height * (height / 800);
82
103
  const sigma = band.sigma * scale;
83
104
  const cutoff = sigma * 3.5;
@@ -88,6 +109,21 @@ export class AuroraLayer extends SimulationLayer {
88
109
  const xStart = Math.max(0, Math.floor((cx - cutoff) / step) * step);
89
110
  const xEnd = Math.min(width, Math.ceil((cx + cutoff) / step) * step);
90
111
 
112
+ const centreWave = Math.sin(band.frequency1 * cx + band.phase2) * waveRange;
113
+ const centreBase = baseY + centreWave;
114
+ const centreTop = centreBase - rayHeight;
115
+ const centreFadeBottom = centreBase + rayHeight * 0.1;
116
+
117
+ const gradient = ctx.createLinearGradient(0, centreFadeBottom, 0, centreTop);
118
+ gradient.addColorStop(0, `hsla(${band.hue}, 100%, 90%, 0)`);
119
+ gradient.addColorStop(0.04, `hsla(${band.hue}, 100%, 90%, 0.55)`);
120
+ gradient.addColorStop(0.1, `hsla(${band.hue}, 90%, 72%, 1)`);
121
+ gradient.addColorStop(0.32, `hsla(${band.hue}, 85%, 62%, 0.75)`);
122
+ gradient.addColorStop(0.62, `hsla(${midHue}, 80%, 56%, 0.35)`);
123
+ gradient.addColorStop(0.86, `hsla(${TOP_HUE}, 75%, 50%, 0.12)`);
124
+ gradient.addColorStop(1, `hsla(${TOP_HUE}, 70%, 45%, 0)`);
125
+ ctx.fillStyle = gradient;
126
+
91
127
  for (let x = xStart; x < xEnd; x += step) {
92
128
  const dx = x - cx;
93
129
  const alpha = Math.exp(-dx * dx / sigmaSq2);
@@ -100,20 +136,12 @@ export class AuroraLayer extends SimulationLayer {
100
136
  const colBase = baseY + waveOffset;
101
137
  const colTop = colBase - rayHeight;
102
138
  const fadeBottom = colBase + rayHeight * 0.1;
103
- const eff = alpha * band.opacity;
104
-
105
- const gradient = ctx.createLinearGradient(0, fadeBottom, 0, colTop);
106
- gradient.addColorStop(0, `hsla(${band.hue}, 100%, 90%, 0)`);
107
- gradient.addColorStop(0.04, `hsla(${band.hue}, 100%, 90%, ${eff * 0.55})`);
108
- gradient.addColorStop(0.1, `hsla(${band.hue}, 90%, 72%, ${eff})`);
109
- gradient.addColorStop(0.32, `hsla(${band.hue}, 85%, 62%, ${eff * 0.75})`);
110
- gradient.addColorStop(0.62, `hsla(${midHue}, 80%, 56%, ${eff * 0.35})`);
111
- gradient.addColorStop(0.86, `hsla(${TOP_HUE}, 75%, 50%, ${eff * 0.12})`);
112
- gradient.addColorStop(1, `hsla(${TOP_HUE}, 70%, 45%, 0)`);
113
-
114
- ctx.fillStyle = gradient;
139
+
140
+ ctx.globalAlpha = alpha * band.opacity * this.#intensity;
115
141
  ctx.fillRect(x, colTop, step, fadeBottom - colTop + 1);
116
142
  }
143
+
144
+ ctx.globalAlpha = 1;
117
145
  }
118
146
 
119
147
  ctx.globalCompositeOperation = 'source-over';
@@ -1,6 +1,12 @@
1
- export { BalloonLayer } from './layer';
1
+ import { Balloons } from './layer';
2
+ import type { BalloonsConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createBalloons(config?: BalloonsConfig): Effect<BalloonsConfig> {
6
+ return new Balloons(config);
7
+ }
8
+
2
9
  export { BalloonParticle } from './particle';
3
- export { BalloonSimulation } from './simulation';
10
+ export type { BalloonsConfig };
4
11
  export type { BalloonParticleConfig } from './particle';
5
- export type { BalloonSimulationConfig } from './simulation';
6
12
  export type { Balloon } from './types';
@@ -1,23 +1,32 @@
1
1
  import { hexToRGB } from '@basmilius/utils';
2
- import { SimulationLayer } from '../layer';
2
+ import { Effect } from '../effect';
3
3
  import { MULBERRY } from './consts';
4
- import type { BalloonSimulationConfig } from './simulation';
5
4
  import type { Balloon } from './types';
6
5
 
7
6
  const DEFAULT_COLORS = ['#ff4444', '#4488ff', '#44cc44', '#ffcc00', '#ff88cc', '#8844ff'];
8
7
 
9
- export class BalloonLayer extends SimulationLayer {
8
+ export interface BalloonsConfig {
9
+ readonly count?: number;
10
+ readonly colors?: string[];
11
+ readonly sizeRange?: [number, number];
12
+ readonly speed?: number;
13
+ readonly driftAmount?: number;
14
+ readonly stringLength?: number;
15
+ readonly scale?: number;
16
+ }
17
+
18
+ export class Balloons extends Effect<BalloonsConfig> {
10
19
  readonly #scale: number;
11
- readonly #speed: number;
12
- readonly #driftAmount: number;
13
- readonly #stringLengthMul: number;
20
+ #speed: number;
21
+ #driftAmount: number;
22
+ #stringLengthMul: number;
14
23
  readonly #sizeRange: [number, number];
15
24
  readonly #colorRGBs: [number, number, number][];
16
25
  #maxCount: number;
17
26
  #time: number = 0;
18
27
  #balloons: Balloon[] = [];
19
28
 
20
- constructor(config: BalloonSimulationConfig = {}) {
29
+ constructor(config: BalloonsConfig = {}) {
21
30
  super();
22
31
 
23
32
  this.#scale = config.scale ?? 1;
@@ -39,6 +48,18 @@ export class BalloonLayer extends SimulationLayer {
39
48
  }
40
49
  }
41
50
 
51
+ configure(config: Partial<BalloonsConfig>): void {
52
+ if (config.speed !== undefined) {
53
+ this.#speed = config.speed;
54
+ }
55
+ if (config.driftAmount !== undefined) {
56
+ this.#driftAmount = config.driftAmount;
57
+ }
58
+ if (config.stringLength !== undefined) {
59
+ this.#stringLengthMul = config.stringLength;
60
+ }
61
+ }
62
+
42
63
  tick(dt: number, width: number, height: number): void {
43
64
  this.#time += 0.015 * dt * this.#speed;
44
65
 
@@ -66,10 +87,10 @@ export class BalloonLayer extends SimulationLayer {
66
87
  const rx = balloon.radiusX * this.#scale;
67
88
  const ry = balloon.radiusY * this.#scale;
68
89
  const [r, g, b] = balloon.color;
90
+ const cos = Math.cos(balloon.rotation);
91
+ const sin = Math.sin(balloon.rotation);
69
92
 
70
- ctx.save();
71
- ctx.translate(px, py);
72
- ctx.rotate(balloon.rotation);
93
+ ctx.setTransform(cos, sin, -sin, cos, px, py);
73
94
 
74
95
  const gradient = ctx.createRadialGradient(
75
96
  -rx * 0.3, -ry * 0.3, rx * 0.1,
@@ -99,21 +120,31 @@ export class BalloonLayer extends SimulationLayer {
99
120
  ctx.fill();
100
121
 
101
122
  const stringLen = balloon.stringLength * this.#scale * this.#stringLengthMul;
102
- const drift = Math.sin(this.#time * 2 + balloon.driftPhase) * 8 * this.#scale * this.#driftAmount;
123
+ const knotBaseY = knotY + 5 * this.#scale;
124
+ const ph = balloon.driftPhase;
125
+ const fr = balloon.driftFreq;
126
+ const swingAmt = 10 * this.#scale * this.#driftAmount;
127
+
128
+ // Each control point lags further behind the balloon's lateral oscillation,
129
+ // so the string trails the direction of movement like a real hanging string.
130
+ const midSwing = Math.sin(this.#time * fr + ph - 0.3) * swingAmt * 0.55;
131
+ const tipSwing = Math.sin(this.#time * fr + ph - 0.8) * swingAmt;
132
+ // Subtle high-frequency flutter at the tip for lightness.
133
+ const flutter = Math.sin(this.#time * fr * 2.5 + ph * 1.4 + 1.8) * 2.5 * this.#scale;
134
+
103
135
  ctx.beginPath();
104
- ctx.moveTo(0, knotY + 5 * this.#scale);
105
- ctx.quadraticCurveTo(
106
- drift,
107
- knotY + 5 * this.#scale + stringLen * 0.5,
108
- -drift * 0.5,
109
- knotY + 5 * this.#scale + stringLen
136
+ ctx.moveTo(0, knotBaseY);
137
+ ctx.bezierCurveTo(
138
+ midSwing * 0.35, knotBaseY + stringLen * 0.3,
139
+ midSwing + flutter * 0.5, knotBaseY + stringLen * 0.65,
140
+ tipSwing + flutter, knotBaseY + stringLen
110
141
  );
111
142
  ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, 0.4)`;
112
143
  ctx.lineWidth = 1;
113
144
  ctx.stroke();
114
-
115
- ctx.restore();
116
145
  }
146
+
147
+ ctx.resetTransform();
117
148
  }
118
149
 
119
150
  #createBalloon(initialSpread: boolean): Balloon {
@@ -1,4 +1,10 @@
1
- export { BubbleLayer } from './layer';
2
- export { BubbleSimulation } from './simulation';
3
- export type { BubbleSimulationConfig } from './simulation';
1
+ import { Bubbles } from './layer';
2
+ import type { BubblesConfig } from './layer';
3
+ import type { Effect } from '../effect';
4
+
5
+ export function createBubbles(config?: BubblesConfig): Effect<BubblesConfig> {
6
+ return new Bubbles(config);
7
+ }
8
+
9
+ export type { BubblesConfig };
4
10
  export type { Bubble, PopParticle } from './types';
@@ -1,15 +1,26 @@
1
- import { SimulationLayer } from '../layer';
1
+ import { parseColor } from '../color';
2
+ import { Effect } from '../effect';
2
3
  import { MULBERRY } from './consts';
3
- import type { BubbleSimulationConfig } from './simulation';
4
4
  import type { Bubble, PopParticle } from './types';
5
5
 
6
6
  const DEFAULT_COLORS = ['#88ccff', '#aaddff', '#ccbbff'];
7
7
 
8
- export class BubbleLayer extends SimulationLayer {
8
+ export interface BubblesConfig {
9
+ readonly count?: number;
10
+ readonly sizeRange?: [number, number];
11
+ readonly speed?: number;
12
+ readonly popOnClick?: boolean;
13
+ readonly popRadius?: number;
14
+ readonly colors?: string[];
15
+ readonly wobbleAmount?: number;
16
+ readonly scale?: number;
17
+ }
18
+
19
+ export class Bubbles extends Effect<BubblesConfig> {
9
20
  readonly #scale: number;
10
- readonly #speed: number;
21
+ #speed: number;
11
22
  readonly #sizeRange: [number, number];
12
- readonly #wobbleAmount: number;
23
+ #wobbleAmount: number;
13
24
  readonly #popOnClick: boolean;
14
25
  readonly #popRadius: number;
15
26
  readonly #baseHues: number[];
@@ -20,7 +31,7 @@ export class BubbleLayer extends SimulationLayer {
20
31
  #popParticles: PopParticle[] = [];
21
32
  #canvas: HTMLCanvasElement | null = null;
22
33
 
23
- constructor(config: BubbleSimulationConfig = {}) {
34
+ constructor(config: BubblesConfig = {}) {
24
35
  super();
25
36
 
26
37
  this.#scale = config.scale ?? 1;
@@ -58,6 +69,15 @@ export class BubbleLayer extends SimulationLayer {
58
69
  this.#canvas = null;
59
70
  }
60
71
 
72
+ configure(config: Partial<BubblesConfig>): void {
73
+ if (config.speed !== undefined) {
74
+ this.#speed = config.speed;
75
+ }
76
+ if (config.wobbleAmount !== undefined) {
77
+ this.#wobbleAmount = config.wobbleAmount;
78
+ }
79
+ }
80
+
61
81
  tick(dt: number, width: number, height: number): void {
62
82
  this.#time += 0.01 * dt;
63
83
 
@@ -193,17 +213,10 @@ export class BubbleLayer extends SimulationLayer {
193
213
  }
194
214
 
195
215
  #colorToHue(color: string): number {
196
- const canvas = document.createElement('canvas');
197
- canvas.width = 1;
198
- canvas.height = 1;
199
- const ctx = canvas.getContext('2d')!;
200
- ctx.fillStyle = color;
201
- ctx.fillRect(0, 0, 1, 1);
202
- const data = ctx.getImageData(0, 0, 1, 1).data;
203
-
204
- let r = data[0] / 255;
205
- let g = data[1] / 255;
206
- let b = data[2] / 255;
216
+ const {r: r255, g: g255, b: b255} = parseColor(color);
217
+ let r = r255 / 255;
218
+ let g = g255 / 255;
219
+ let b = b255 / 255;
207
220
  const max = Math.max(r, g, b);
208
221
  const min = Math.min(r, g, b);
209
222
  const delta = max - min;
package/src/canvas.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export class LimitedFrameRateCanvas {
2
2
  static #globalSpeed: number = 1;
3
+ static #globalFrameRate: number | null = null;
4
+ static #showFps: boolean = false;
3
5
 
4
6
  static get globalSpeed(): number {
5
7
  return LimitedFrameRateCanvas.#globalSpeed;
@@ -9,6 +11,28 @@ export class LimitedFrameRateCanvas {
9
11
  LimitedFrameRateCanvas.#globalSpeed = value;
10
12
  }
11
13
 
14
+ /**
15
+ * Global frame rate override for all canvas instances.
16
+ * null = use each instance's own frame rate.
17
+ * 0 = unlimited (render as fast as the browser allows).
18
+ * Any positive number = cap at that many frames per second.
19
+ */
20
+ static get globalFrameRate(): number | null {
21
+ return LimitedFrameRateCanvas.#globalFrameRate;
22
+ }
23
+
24
+ static set globalFrameRate(value: number | null) {
25
+ LimitedFrameRateCanvas.#globalFrameRate = value;
26
+ }
27
+
28
+ static get showFps(): boolean {
29
+ return LimitedFrameRateCanvas.#showFps;
30
+ }
31
+
32
+ static set showFps(value: boolean) {
33
+ LimitedFrameRateCanvas.#showFps = value;
34
+ }
35
+
12
36
  readonly #canvas: HTMLCanvasElement;
13
37
  readonly #context: CanvasRenderingContext2D;
14
38
  readonly #frameRate: number;
@@ -23,6 +47,9 @@ export class LimitedFrameRateCanvas {
23
47
  #isStopped: boolean = true;
24
48
  #height: number = 540;
25
49
  #width: number = 960;
50
+ #fps: string = '0.0';
51
+ #fpsFrames: number = 0;
52
+ #fpsTime: number = 0;
26
53
 
27
54
  get canvas(): HTMLCanvasElement {
28
55
  return this.#canvas;
@@ -48,6 +75,10 @@ export class LimitedFrameRateCanvas {
48
75
  this.#speed = value;
49
76
  }
50
77
 
78
+ get dpr(): number {
79
+ return devicePixelRatio;
80
+ }
81
+
51
82
  get frameRate(): number {
52
83
  return this.#frameRate;
53
84
  }
@@ -76,7 +107,7 @@ export class LimitedFrameRateCanvas {
76
107
  this.#canvas = canvas;
77
108
  this.#context = canvas.getContext('2d', options);
78
109
  this.#frameRate = frameRate;
79
- this.#target = 1000 / frameRate;
110
+ this.#target = frameRate > 0 ? 1000 / frameRate : 0;
80
111
 
81
112
  this.onVisibilityChange = this.onVisibilityChange.bind(this);
82
113
  this.onResize = this.onResize.bind(this);
@@ -93,7 +124,10 @@ export class LimitedFrameRateCanvas {
93
124
  this.#current = Date.now();
94
125
  this.#frame = requestAnimationFrame(this.loop.bind(this));
95
126
 
96
- if (this.#then > 0 && this.#current - this.#then + 1 < this.#target) {
127
+ const globalRate = LimitedFrameRateCanvas.#globalFrameRate;
128
+ const effectiveTarget = globalRate !== null ? (globalRate > 0 ? 1000 / globalRate : 0) : this.#target;
129
+
130
+ if (effectiveTarget > 0 && this.#then > 0 && this.#current - this.#then + 1 < effectiveTarget) {
97
131
  return;
98
132
  }
99
133
 
@@ -105,6 +139,24 @@ export class LimitedFrameRateCanvas {
105
139
  this.tick();
106
140
  this.draw();
107
141
 
142
+ if (LimitedFrameRateCanvas.#showFps) {
143
+ ++this.#fpsFrames;
144
+
145
+ if (this.#fpsTime === 0) {
146
+ this.#fpsTime = this.#current;
147
+ } else {
148
+ const elapsed = this.#current - this.#fpsTime;
149
+
150
+ if (elapsed >= 1000) {
151
+ this.#fps = (Math.round(this.#fpsFrames * 10000 / elapsed) / 10).toFixed(1);
152
+ this.#fpsFrames = 0;
153
+ this.#fpsTime = this.#current;
154
+ }
155
+ }
156
+
157
+ this.#drawFps();
158
+ }
159
+
108
160
  this.#then = this.#now;
109
161
  }
110
162
 
@@ -120,6 +172,44 @@ export class LimitedFrameRateCanvas {
120
172
  cancelAnimationFrame(this.#frame);
121
173
  }
122
174
 
175
+ pause(): void {
176
+ this.#isStopped = true;
177
+ cancelAnimationFrame(this.#frame);
178
+ }
179
+
180
+ resume(): void {
181
+ if (this.#isStopped) {
182
+ this.#isStopped = false;
183
+ this.#frame = requestAnimationFrame(this.loop.bind(this));
184
+ }
185
+ }
186
+
187
+ #drawFps(): void {
188
+ const ctx = this.#context;
189
+ const text = `${this.#fps} FPS`;
190
+ const x = 9;
191
+ const y = 9;
192
+ const paddingX = 6;
193
+ const paddingY = 4;
194
+
195
+ ctx.save();
196
+ ctx.font = '700 10px ui-monospace, monospace';
197
+
198
+ const textWidth = ctx.measureText(text).width;
199
+ const boxWidth = textWidth + paddingX * 2;
200
+ const boxHeight = 11 + paddingY * 2;
201
+
202
+ ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
203
+ ctx.beginPath();
204
+ ctx.roundRect(x, y, boxWidth, boxHeight, 3);
205
+ ctx.fill();
206
+
207
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
208
+ ctx.textBaseline = 'middle';
209
+ ctx.fillText(text, x + paddingX, y + boxHeight / 1.9);
210
+ ctx.restore();
211
+ }
212
+
123
213
  draw(): void {
124
214
  throw new Error('LimitedFrameRateCanvas::draw() should be overwritten.');
125
215
  }
package/src/color.ts CHANGED
@@ -1,4 +1,11 @@
1
- export function parseColor(fillStyle: string): {r: number; g: number; b: number; a: number} {
1
+ const cache = new Map<string, { r: number; g: number; b: number; a: number }>();
2
+
3
+ export function parseColor(fillStyle: string): { r: number; g: number; b: number; a: number } {
4
+ const cached = cache.get(fillStyle);
5
+ if (cached) {
6
+ return cached;
7
+ }
8
+
2
9
  const canvas = document.createElement('canvas');
3
10
  canvas.width = 1;
4
11
  canvas.height = 1;
@@ -6,5 +13,7 @@ export function parseColor(fillStyle: string): {r: number; g: number; b: number;
6
13
  ctx.fillStyle = fillStyle;
7
14
  ctx.fillRect(0, 0, 1, 1);
8
15
  const data = ctx.getImageData(0, 0, 1, 1).data;
9
- return {r: data[0], g: data[1], b: data[2], a: data[3] / 255};
16
+ const result = {r: data[0], g: data[1], b: data[2], a: data[3] / 255};
17
+ cache.set(fillStyle, result);
18
+ return result;
10
19
  }
@@ -1,8 +1,20 @@
1
- export { ConfettiLayer } from './layer';
1
+ import { Confetti } from './layer';
2
+ import type { ConfettiConfig } from './layer';
3
+ import type { Config as ConfettiBurstConfig } from './types';
4
+ import type { Effect } from '../effect';
5
+
6
+ export interface ConfettiInstance extends Effect<ConfettiConfig> {
7
+ burst(config: Partial<ConfettiBurstConfig>): void;
8
+ }
9
+
10
+ export function createConfetti(config?: ConfettiConfig): ConfettiInstance {
11
+ return new Confetti(config) as ConfettiInstance;
12
+ }
13
+
2
14
  export { ConfettiParticle } from './particle';
3
- export { ConfettiSimulation } from './simulation';
4
15
  export { PALETTES } from './consts';
5
16
  export { SHAPE_PATHS } from './shapes';
17
+ export type { ConfettiConfig };
18
+ export type { ConfettiBurstConfig };
6
19
  export type { ConfettiParticleConfig } from './particle';
7
- export type { ConfettiSimulationConfig } from './simulation';
8
20
  export type { Palette, Shape as ConfettiShape } from './types';
@@ -1,20 +1,23 @@
1
1
  import { hexToRGB } from '@basmilius/utils';
2
- import { SimulationLayer } from '../layer';
2
+ import { Effect } from '../effect';
3
3
  import { DEFAULT_CONFIG, MULBERRY, PALETTES } from './consts';
4
4
  import { SHAPE_PATHS } from './shapes';
5
- import type { ConfettiSimulationConfig } from './simulation';
6
5
  import type { Config, Particle, ParticleConfig } from './types';
7
6
 
8
7
  const TWO_PI = Math.PI * 2;
9
8
 
10
- export class ConfettiLayer extends SimulationLayer {
9
+ export interface ConfettiConfig {
10
+ readonly scale?: number;
11
+ }
12
+
13
+ export class Confetti extends Effect<ConfettiConfig> {
11
14
  readonly #scale: number;
12
15
  #particles: Particle[] = [];
13
16
  #width: number = 0;
14
17
  #height: number = 0;
15
18
  #isFiring: boolean = false;
16
19
 
17
- constructor(config: ConfettiSimulationConfig = {}) {
20
+ constructor(config: ConfettiConfig = {}) {
18
21
  super();
19
22
  this.#scale = config.scale ?? 1;
20
23
  }
@@ -24,7 +27,7 @@ export class ConfettiLayer extends SimulationLayer {
24
27
  this.#height = height;
25
28
  }
26
29
 
27
- fire(config: Partial<Config>): void {
30
+ burst(config: Partial<Config>): void {
28
31
  const width = this.#width;
29
32
  const height = this.#height;
30
33