@konfetti-js/core 0.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.
- package/LICENSE +21 -0
- package/README.md +27 -0
- package/dist/index.cjs +833 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +198 -0
- package/dist/index.d.ts +198 -0
- package/dist/index.js +789 -0
- package/dist/index.js.map +1 -0
- package/dist/konfetti.min.js +2 -0
- package/dist/konfetti.min.js.map +1 -0
- package/package.json +66 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,789 @@
|
|
|
1
|
+
// src/shapes.ts
|
|
2
|
+
function drawCircle(ctx, size) {
|
|
3
|
+
ctx.beginPath();
|
|
4
|
+
ctx.arc(0, 0, size / 2, 0, Math.PI * 2);
|
|
5
|
+
ctx.fill();
|
|
6
|
+
}
|
|
7
|
+
function drawSquare(ctx, size) {
|
|
8
|
+
const halfSize = size / 2;
|
|
9
|
+
ctx.fillRect(-halfSize, -halfSize, size, size);
|
|
10
|
+
}
|
|
11
|
+
var SHAPES = ["circle", "square"];
|
|
12
|
+
var RENDERERS = [drawCircle, drawSquare];
|
|
13
|
+
function isShape(value) {
|
|
14
|
+
return SHAPES.includes(value);
|
|
15
|
+
}
|
|
16
|
+
function shapeToIndex(shape) {
|
|
17
|
+
return Math.max(0, SHAPES.indexOf(shape));
|
|
18
|
+
}
|
|
19
|
+
function getShapeRenderer(index) {
|
|
20
|
+
return RENDERERS[index] ?? drawCircle;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/utils.ts
|
|
24
|
+
var DEFAULT_COLORS = [
|
|
25
|
+
"#26ccff",
|
|
26
|
+
// cyan
|
|
27
|
+
"#a25afd",
|
|
28
|
+
// purple
|
|
29
|
+
"#ff5e7e",
|
|
30
|
+
// pink
|
|
31
|
+
"#88ff5a",
|
|
32
|
+
// green
|
|
33
|
+
"#fcff42",
|
|
34
|
+
// yellow
|
|
35
|
+
"#ffa62d",
|
|
36
|
+
// orange
|
|
37
|
+
"#ff36ff"
|
|
38
|
+
// magenta
|
|
39
|
+
];
|
|
40
|
+
var DEFAULT_OPTIONS = {
|
|
41
|
+
particleCount: 50,
|
|
42
|
+
angle: 90,
|
|
43
|
+
spread: 45,
|
|
44
|
+
startVelocity: 45,
|
|
45
|
+
decay: 0.9,
|
|
46
|
+
gravity: 1,
|
|
47
|
+
drift: 0,
|
|
48
|
+
ticks: 200,
|
|
49
|
+
origin: { x: 0.5, y: 0.5 },
|
|
50
|
+
colors: DEFAULT_COLORS,
|
|
51
|
+
shapes: ["circle", "square"],
|
|
52
|
+
scalar: 1,
|
|
53
|
+
zIndex: 100,
|
|
54
|
+
disableForReducedMotion: true
|
|
55
|
+
};
|
|
56
|
+
function finite(value, fallback) {
|
|
57
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
58
|
+
}
|
|
59
|
+
function resolveOptions(options = {}) {
|
|
60
|
+
const d = DEFAULT_OPTIONS;
|
|
61
|
+
const colors = Array.isArray(options.colors) ? options.colors.filter((c) => typeof c === "string" && c.length > 0) : [];
|
|
62
|
+
const shapes = Array.isArray(options.shapes) ? options.shapes.filter(isShape) : [];
|
|
63
|
+
return {
|
|
64
|
+
particleCount: Math.max(0, Math.round(finite(options.particleCount, d.particleCount))),
|
|
65
|
+
angle: finite(options.angle, d.angle),
|
|
66
|
+
spread: finite(options.spread, d.spread),
|
|
67
|
+
startVelocity: finite(options.startVelocity, d.startVelocity),
|
|
68
|
+
decay: clamp(finite(options.decay, d.decay), 0, 1),
|
|
69
|
+
gravity: finite(options.gravity, d.gravity),
|
|
70
|
+
drift: finite(options.drift, d.drift),
|
|
71
|
+
ticks: Math.max(1, finite(options.ticks, d.ticks)),
|
|
72
|
+
origin: {
|
|
73
|
+
x: finite(options.origin?.x, d.origin.x),
|
|
74
|
+
y: finite(options.origin?.y, d.origin.y)
|
|
75
|
+
},
|
|
76
|
+
colors: colors.length > 0 ? colors : [...d.colors],
|
|
77
|
+
shapes: shapes.length > 0 ? shapes : [...d.shapes],
|
|
78
|
+
scalar: Math.max(0, finite(options.scalar, d.scalar)),
|
|
79
|
+
zIndex: finite(options.zIndex, d.zIndex),
|
|
80
|
+
disableForReducedMotion: options.disableForReducedMotion ?? d.disableForReducedMotion
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function mergeOptions(base, override) {
|
|
84
|
+
const merged = { ...base };
|
|
85
|
+
if (override) {
|
|
86
|
+
for (const [key, value] of Object.entries(override)) {
|
|
87
|
+
if (value !== void 0) merged[key] = value;
|
|
88
|
+
}
|
|
89
|
+
if (override.origin) merged.origin = mergeOrigin(base.origin, override.origin);
|
|
90
|
+
}
|
|
91
|
+
return merged;
|
|
92
|
+
}
|
|
93
|
+
function mergeOrigin(base, override) {
|
|
94
|
+
return {
|
|
95
|
+
...base,
|
|
96
|
+
...override?.x !== void 0 && { x: override.x },
|
|
97
|
+
...override?.y !== void 0 && { y: override.y }
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function degToRad(deg) {
|
|
101
|
+
return deg * (Math.PI / 180);
|
|
102
|
+
}
|
|
103
|
+
function randomRange(min, max) {
|
|
104
|
+
return Math.random() * (max - min) + min;
|
|
105
|
+
}
|
|
106
|
+
function randomItem(arr) {
|
|
107
|
+
return arr[Math.floor(Math.random() * arr.length)];
|
|
108
|
+
}
|
|
109
|
+
function isBrowser() {
|
|
110
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
111
|
+
}
|
|
112
|
+
function prefersReducedMotion() {
|
|
113
|
+
if (typeof window === "undefined") return false;
|
|
114
|
+
const mediaQuery = window.matchMedia?.("(prefers-reduced-motion: reduce)");
|
|
115
|
+
return mediaQuery?.matches ?? false;
|
|
116
|
+
}
|
|
117
|
+
function clamp(value, min, max) {
|
|
118
|
+
return Math.min(Math.max(value, min), max);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// src/particle.ts
|
|
122
|
+
var MAX_PARTICLES = 1e4;
|
|
123
|
+
var MAX_PALETTE = 256;
|
|
124
|
+
var ParticlePool = class {
|
|
125
|
+
constructor(initialCapacity = 512) {
|
|
126
|
+
/** Number of active particles */
|
|
127
|
+
this.activeCount = 0;
|
|
128
|
+
/**
|
|
129
|
+
* Colors in use; particles store an index into this list, so any CSS color
|
|
130
|
+
* works without parsing it
|
|
131
|
+
*/
|
|
132
|
+
this.palette = [];
|
|
133
|
+
this.colorIds = /* @__PURE__ */ new Map();
|
|
134
|
+
this.data = new Float32Array(initialCapacity * 16 /* SIZE */);
|
|
135
|
+
}
|
|
136
|
+
/** How many particles fit before the buffer has to grow */
|
|
137
|
+
get capacity() {
|
|
138
|
+
return this.data.length / 16 /* SIZE */;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Remove every particle
|
|
142
|
+
*/
|
|
143
|
+
reset() {
|
|
144
|
+
this.activeCount = 0;
|
|
145
|
+
this.clearPalette();
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Spawn particles with the given options
|
|
149
|
+
*/
|
|
150
|
+
spawn(options, canvasWidth, canvasHeight) {
|
|
151
|
+
const count = Math.min(options.particleCount, MAX_PARTICLES - this.activeCount);
|
|
152
|
+
if (count <= 0) return;
|
|
153
|
+
this.ensureCapacity(this.activeCount + count);
|
|
154
|
+
const data = this.data;
|
|
155
|
+
const originX = options.origin.x * canvasWidth;
|
|
156
|
+
const originY = options.origin.y * canvasHeight;
|
|
157
|
+
const angleRad = degToRad(options.angle);
|
|
158
|
+
const spreadRad = degToRad(options.spread);
|
|
159
|
+
for (let i = 0; i < count; i++) {
|
|
160
|
+
const idx = (this.activeCount + i) * 16 /* SIZE */;
|
|
161
|
+
const particleAngle = angleRad + randomRange(-spreadRad / 2, spreadRad / 2);
|
|
162
|
+
const velocity = options.startVelocity * randomRange(0.5, 1);
|
|
163
|
+
data[idx + 0 /* X */] = originX;
|
|
164
|
+
data[idx + 1 /* Y */] = originY;
|
|
165
|
+
data[idx + 2 /* VelocityX */] = Math.cos(particleAngle) * velocity;
|
|
166
|
+
data[idx + 3 /* VelocityY */] = -Math.sin(particleAngle) * velocity;
|
|
167
|
+
data[idx + 4 /* Rotation */] = randomRange(0, Math.PI * 2);
|
|
168
|
+
data[idx + 5 /* RotationSpeed */] = randomRange(-0.1, 0.1);
|
|
169
|
+
data[idx + 6 /* Tilt */] = randomRange(0, Math.PI * 2);
|
|
170
|
+
data[idx + 7 /* TiltSpeed */] = randomRange(0.05, 0.15);
|
|
171
|
+
data[idx + 8 /* Color */] = this.colorId(randomItem(options.colors));
|
|
172
|
+
data[idx + 9 /* Shape */] = shapeToIndex(randomItem(options.shapes));
|
|
173
|
+
data[idx + 10 /* Scalar */] = options.scalar * randomRange(0.8, 1.2);
|
|
174
|
+
data[idx + 11 /* Life */] = options.ticks;
|
|
175
|
+
data[idx + 12 /* MaxLife */] = options.ticks;
|
|
176
|
+
data[idx + 13 /* Drift */] = options.drift + randomRange(-0.5, 0.5);
|
|
177
|
+
data[idx + 14 /* Gravity */] = options.gravity;
|
|
178
|
+
data[idx + 15 /* Decay */] = options.decay;
|
|
179
|
+
}
|
|
180
|
+
this.activeCount += count;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Remove dead particles, keeping live ones packed at the front
|
|
184
|
+
*/
|
|
185
|
+
compact() {
|
|
186
|
+
const data = this.data;
|
|
187
|
+
let writeIdx = 0;
|
|
188
|
+
for (let readIdx = 0; readIdx < this.activeCount; readIdx++) {
|
|
189
|
+
const srcIdx = readIdx * 16 /* SIZE */;
|
|
190
|
+
if (data[srcIdx + 11 /* Life */] <= 0) continue;
|
|
191
|
+
if (writeIdx !== readIdx) {
|
|
192
|
+
const dstIdx = writeIdx * 16 /* SIZE */;
|
|
193
|
+
data.copyWithin(dstIdx, srcIdx, srcIdx + 16 /* SIZE */);
|
|
194
|
+
}
|
|
195
|
+
writeIdx++;
|
|
196
|
+
}
|
|
197
|
+
this.activeCount = writeIdx;
|
|
198
|
+
if (writeIdx === 0) {
|
|
199
|
+
this.clearPalette();
|
|
200
|
+
} else if (this.palette.length > MAX_PALETTE) {
|
|
201
|
+
this.prunePalette();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Keep only colors that live particles still use, renumbering them, so a long
|
|
206
|
+
* stream with random colors does not grow the palette forever
|
|
207
|
+
*/
|
|
208
|
+
prunePalette() {
|
|
209
|
+
const data = this.data;
|
|
210
|
+
const oldPalette = [...this.palette];
|
|
211
|
+
const remap = /* @__PURE__ */ new Map();
|
|
212
|
+
this.clearPalette();
|
|
213
|
+
for (let i = 0; i < this.activeCount; i++) {
|
|
214
|
+
const idx = i * 16 /* SIZE */ + 8 /* Color */;
|
|
215
|
+
const oldId = data[idx];
|
|
216
|
+
let newId = remap.get(oldId);
|
|
217
|
+
if (newId === void 0) {
|
|
218
|
+
newId = this.colorId(oldPalette[oldId]);
|
|
219
|
+
remap.set(oldId, newId);
|
|
220
|
+
}
|
|
221
|
+
data[idx] = newId;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
colorId(color) {
|
|
225
|
+
let id = this.colorIds.get(color);
|
|
226
|
+
if (id === void 0) {
|
|
227
|
+
id = this.palette.length;
|
|
228
|
+
this.palette.push(color);
|
|
229
|
+
this.colorIds.set(color, id);
|
|
230
|
+
}
|
|
231
|
+
return id;
|
|
232
|
+
}
|
|
233
|
+
clearPalette() {
|
|
234
|
+
this.palette.length = 0;
|
|
235
|
+
this.colorIds.clear();
|
|
236
|
+
}
|
|
237
|
+
ensureCapacity(needed) {
|
|
238
|
+
if (needed <= this.capacity) return;
|
|
239
|
+
let capacity = this.capacity;
|
|
240
|
+
while (capacity < needed) capacity *= 2;
|
|
241
|
+
const grown = new Float32Array(Math.min(capacity, MAX_PARTICLES) * 16 /* SIZE */);
|
|
242
|
+
grown.set(this.data.subarray(0, this.activeCount * 16 /* SIZE */));
|
|
243
|
+
this.data = grown;
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
// src/physics.ts
|
|
248
|
+
function updateParticles(pool, dt) {
|
|
249
|
+
const data = pool.data;
|
|
250
|
+
for (let i = 0; i < pool.activeCount; i++) {
|
|
251
|
+
const idx = i * 16 /* SIZE */;
|
|
252
|
+
let vx = data[idx + 2 /* VelocityX */];
|
|
253
|
+
let vy = data[idx + 3 /* VelocityY */];
|
|
254
|
+
const tilt = data[idx + 6 /* Tilt */];
|
|
255
|
+
vy += data[idx + 14 /* Gravity */] * dt;
|
|
256
|
+
vx += data[idx + 13 /* Drift */] * Math.sin(tilt) * dt;
|
|
257
|
+
const damping = data[idx + 15 /* Decay */] ** dt;
|
|
258
|
+
vx *= damping;
|
|
259
|
+
vy *= damping;
|
|
260
|
+
data[idx + 2 /* VelocityX */] = vx;
|
|
261
|
+
data[idx + 3 /* VelocityY */] = vy;
|
|
262
|
+
data[idx + 0 /* X */] += vx * dt;
|
|
263
|
+
data[idx + 1 /* Y */] += vy * dt;
|
|
264
|
+
data[idx + 4 /* Rotation */] += data[idx + 5 /* RotationSpeed */] * dt;
|
|
265
|
+
data[idx + 6 /* Tilt */] = tilt + data[idx + 7 /* TiltSpeed */] * dt;
|
|
266
|
+
data[idx + 11 /* Life */] -= dt;
|
|
267
|
+
}
|
|
268
|
+
pool.compact();
|
|
269
|
+
return pool.activeCount > 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// src/presets.ts
|
|
273
|
+
var FIREWORK_COLORS = ["#ff0000", "#ffff00", "#00ff00", "#00ffff", "#ff00ff"];
|
|
274
|
+
var FIREWORK = {
|
|
275
|
+
particleCount: 80,
|
|
276
|
+
angle: 90,
|
|
277
|
+
spread: 360,
|
|
278
|
+
startVelocity: 35,
|
|
279
|
+
decay: 0.91,
|
|
280
|
+
gravity: 1,
|
|
281
|
+
colors: FIREWORK_COLORS
|
|
282
|
+
};
|
|
283
|
+
var PRIDE_COLORS = [
|
|
284
|
+
"#e40303",
|
|
285
|
+
// red
|
|
286
|
+
"#ff8c00",
|
|
287
|
+
// orange
|
|
288
|
+
"#ffed00",
|
|
289
|
+
// yellow
|
|
290
|
+
"#008026",
|
|
291
|
+
// green
|
|
292
|
+
"#004dff",
|
|
293
|
+
// blue
|
|
294
|
+
"#750787"
|
|
295
|
+
// purple
|
|
296
|
+
];
|
|
297
|
+
var PRESETS = {
|
|
298
|
+
// Burst of confetti from bottom center shooting up
|
|
299
|
+
cannon: {
|
|
300
|
+
followsOrigin: true,
|
|
301
|
+
bursts: [
|
|
302
|
+
{
|
|
303
|
+
options: {
|
|
304
|
+
particleCount: 100,
|
|
305
|
+
angle: 90,
|
|
306
|
+
spread: 70,
|
|
307
|
+
startVelocity: 50,
|
|
308
|
+
decay: 0.91,
|
|
309
|
+
gravity: 1,
|
|
310
|
+
origin: { x: 0.5, y: 1 }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
]
|
|
314
|
+
},
|
|
315
|
+
// 360 degree burst from center
|
|
316
|
+
explosion: {
|
|
317
|
+
followsOrigin: true,
|
|
318
|
+
bursts: [
|
|
319
|
+
{
|
|
320
|
+
options: {
|
|
321
|
+
particleCount: 150,
|
|
322
|
+
angle: 90,
|
|
323
|
+
spread: 360,
|
|
324
|
+
startVelocity: 40,
|
|
325
|
+
decay: 0.92,
|
|
326
|
+
gravity: 0.8,
|
|
327
|
+
origin: { x: 0.5, y: 0.5 }
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
]
|
|
331
|
+
},
|
|
332
|
+
// Three bursts across the top half
|
|
333
|
+
fireworks: {
|
|
334
|
+
followsOrigin: false,
|
|
335
|
+
bursts: [
|
|
336
|
+
{ delay: 0, options: { ...FIREWORK, origin: { x: 0.3, y: 0.4 } } },
|
|
337
|
+
{ delay: 150, options: { ...FIREWORK, origin: { x: 0.5, y: 0.3 } } },
|
|
338
|
+
{ delay: 300, options: { ...FIREWORK, origin: { x: 0.7, y: 0.4 } } }
|
|
339
|
+
]
|
|
340
|
+
},
|
|
341
|
+
// Particles falling from the top
|
|
342
|
+
rain: {
|
|
343
|
+
followsOrigin: false,
|
|
344
|
+
bursts: [
|
|
345
|
+
{
|
|
346
|
+
options: {
|
|
347
|
+
particleCount: 50,
|
|
348
|
+
angle: 270,
|
|
349
|
+
spread: 60,
|
|
350
|
+
startVelocity: 10,
|
|
351
|
+
decay: 0.95,
|
|
352
|
+
gravity: 0.5,
|
|
353
|
+
drift: 0,
|
|
354
|
+
origin: { x: 0.5, y: 0 },
|
|
355
|
+
ticks: 300
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
]
|
|
359
|
+
},
|
|
360
|
+
// Gentle falling white flakes
|
|
361
|
+
snow: {
|
|
362
|
+
followsOrigin: false,
|
|
363
|
+
bursts: [
|
|
364
|
+
{
|
|
365
|
+
options: {
|
|
366
|
+
particleCount: 30,
|
|
367
|
+
angle: 270,
|
|
368
|
+
spread: 120,
|
|
369
|
+
startVelocity: 5,
|
|
370
|
+
decay: 0.98,
|
|
371
|
+
gravity: 0.3,
|
|
372
|
+
drift: 1,
|
|
373
|
+
origin: { x: 0.5, y: -0.1 },
|
|
374
|
+
ticks: 400,
|
|
375
|
+
colors: ["#ffffff", "#e0e0e0", "#c0c0c0"],
|
|
376
|
+
shapes: ["circle"]
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
]
|
|
380
|
+
},
|
|
381
|
+
// Bursts from both sides
|
|
382
|
+
sideCannons: {
|
|
383
|
+
followsOrigin: false,
|
|
384
|
+
bursts: [
|
|
385
|
+
{
|
|
386
|
+
options: {
|
|
387
|
+
particleCount: 50,
|
|
388
|
+
angle: 60,
|
|
389
|
+
spread: 55,
|
|
390
|
+
startVelocity: 50,
|
|
391
|
+
origin: { x: 0, y: 0.7 }
|
|
392
|
+
}
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
options: {
|
|
396
|
+
particleCount: 50,
|
|
397
|
+
angle: 120,
|
|
398
|
+
spread: 55,
|
|
399
|
+
startVelocity: 50,
|
|
400
|
+
origin: { x: 1, y: 0.7 }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
]
|
|
404
|
+
},
|
|
405
|
+
// Rainbow colors
|
|
406
|
+
pride: {
|
|
407
|
+
followsOrigin: true,
|
|
408
|
+
bursts: [
|
|
409
|
+
{
|
|
410
|
+
options: {
|
|
411
|
+
particleCount: 100,
|
|
412
|
+
spread: 70,
|
|
413
|
+
origin: { x: 0.5, y: 0.6 },
|
|
414
|
+
colors: PRIDE_COLORS
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
]
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
var CONTINUOUS_DEFAULTS = {
|
|
421
|
+
particleCount: 10,
|
|
422
|
+
angle: 90,
|
|
423
|
+
spread: 55,
|
|
424
|
+
startVelocity: 45,
|
|
425
|
+
origin: { x: 0.5, y: 1 }
|
|
426
|
+
};
|
|
427
|
+
var PRESET_NAMES = Object.keys(PRESETS);
|
|
428
|
+
function isPresetName(value) {
|
|
429
|
+
return typeof value === "string" && PRESET_NAMES.includes(value);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// src/renderer.ts
|
|
433
|
+
var BASE_SIZE = 10;
|
|
434
|
+
function createOverlayCanvas() {
|
|
435
|
+
const canvas = document.createElement("canvas");
|
|
436
|
+
canvas.className = "konfetti-canvas";
|
|
437
|
+
canvas.setAttribute("aria-hidden", "true");
|
|
438
|
+
Object.assign(canvas.style, {
|
|
439
|
+
position: "fixed",
|
|
440
|
+
top: "0",
|
|
441
|
+
left: "0",
|
|
442
|
+
width: "100%",
|
|
443
|
+
height: "100%",
|
|
444
|
+
pointerEvents: "none",
|
|
445
|
+
zIndex: "100"
|
|
446
|
+
});
|
|
447
|
+
document.body.appendChild(canvas);
|
|
448
|
+
return canvas;
|
|
449
|
+
}
|
|
450
|
+
var Renderer = class {
|
|
451
|
+
constructor(canvas) {
|
|
452
|
+
/** Size in CSS pixels; the backing store is this times the pixel ratio */
|
|
453
|
+
this.width = 0;
|
|
454
|
+
this.height = 0;
|
|
455
|
+
this.pixelRatio = 1;
|
|
456
|
+
/** Particle indices grouped by palette color, reused between frames */
|
|
457
|
+
this.buckets = [];
|
|
458
|
+
this.ownsCanvas = !canvas;
|
|
459
|
+
this.canvas = canvas ?? createOverlayCanvas();
|
|
460
|
+
this.ctx = this.canvas.getContext("2d");
|
|
461
|
+
this.resize();
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Match the canvas to its display size. The full-screen canvas also renders at
|
|
465
|
+
* the screen's pixel density; a canvas you pass in keeps one pixel per CSS
|
|
466
|
+
* pixel, because without a CSS size its display size follows its pixel size
|
|
467
|
+
* and would grow on every resize.
|
|
468
|
+
*/
|
|
469
|
+
resize() {
|
|
470
|
+
if (this.ownsCanvas) {
|
|
471
|
+
this.width = window.innerWidth;
|
|
472
|
+
this.height = window.innerHeight;
|
|
473
|
+
this.pixelRatio = window.devicePixelRatio || 1;
|
|
474
|
+
} else {
|
|
475
|
+
const rect = this.canvas.getBoundingClientRect();
|
|
476
|
+
this.width = rect.width;
|
|
477
|
+
this.height = rect.height;
|
|
478
|
+
this.pixelRatio = 1;
|
|
479
|
+
}
|
|
480
|
+
this.canvas.width = Math.round(this.width * this.pixelRatio);
|
|
481
|
+
this.canvas.height = Math.round(this.height * this.pixelRatio);
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Canvas size in CSS pixels
|
|
485
|
+
*/
|
|
486
|
+
getDimensions() {
|
|
487
|
+
return { width: this.width, height: this.height };
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Set the stacking order; only applies to the full-screen canvas we own
|
|
491
|
+
*/
|
|
492
|
+
setZIndex(zIndex) {
|
|
493
|
+
if (this.ownsCanvas) this.canvas.style.zIndex = String(zIndex);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Clear the canvas
|
|
497
|
+
*/
|
|
498
|
+
clear() {
|
|
499
|
+
if (!this.ctx) return;
|
|
500
|
+
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
501
|
+
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Draw all particles, batched by color to limit fillStyle changes
|
|
505
|
+
*/
|
|
506
|
+
render(pool) {
|
|
507
|
+
this.clear();
|
|
508
|
+
const ctx = this.ctx;
|
|
509
|
+
if (!ctx) return;
|
|
510
|
+
const data = pool.data;
|
|
511
|
+
const ratio = this.pixelRatio;
|
|
512
|
+
const buckets = this.buckets;
|
|
513
|
+
for (const bucket of buckets) bucket.length = 0;
|
|
514
|
+
for (let i = 0; i < pool.activeCount; i++) {
|
|
515
|
+
const colorId = data[i * 16 /* SIZE */ + 8 /* Color */];
|
|
516
|
+
if (!buckets[colorId]) buckets[colorId] = [];
|
|
517
|
+
buckets[colorId].push(i);
|
|
518
|
+
}
|
|
519
|
+
for (let colorId = 0; colorId < buckets.length; colorId++) {
|
|
520
|
+
const bucket = buckets[colorId];
|
|
521
|
+
if (!bucket || bucket.length === 0) continue;
|
|
522
|
+
ctx.fillStyle = pool.palette[colorId];
|
|
523
|
+
for (const particle of bucket) {
|
|
524
|
+
const idx = particle * 16 /* SIZE */;
|
|
525
|
+
const tilt = data[idx + 6 /* Tilt */];
|
|
526
|
+
const rotation = data[idx + 4 /* Rotation */];
|
|
527
|
+
const opacity = Math.min(
|
|
528
|
+
1,
|
|
529
|
+
data[idx + 11 /* Life */] / data[idx + 12 /* MaxLife */] * 2
|
|
530
|
+
);
|
|
531
|
+
const size = BASE_SIZE * data[idx + 10 /* Scalar */] * (0.8 + 0.2 * Math.cos(tilt));
|
|
532
|
+
if (size < 0.5 || opacity < 0.01) continue;
|
|
533
|
+
const cos = Math.cos(rotation);
|
|
534
|
+
const sin = Math.sin(rotation);
|
|
535
|
+
const squash = 0.6 + 0.4 * Math.abs(Math.cos(tilt));
|
|
536
|
+
ctx.globalAlpha = opacity;
|
|
537
|
+
ctx.setTransform(
|
|
538
|
+
ratio * cos,
|
|
539
|
+
ratio * sin,
|
|
540
|
+
-ratio * sin * squash,
|
|
541
|
+
ratio * cos * squash,
|
|
542
|
+
ratio * data[idx + 0 /* X */],
|
|
543
|
+
ratio * data[idx + 1 /* Y */]
|
|
544
|
+
);
|
|
545
|
+
getShapeRenderer(data[idx + 9 /* Shape */])(ctx, size);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
ctx.globalAlpha = 1;
|
|
549
|
+
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Destroy renderer and remove the canvas if we created it
|
|
553
|
+
*/
|
|
554
|
+
destroy() {
|
|
555
|
+
this.clear();
|
|
556
|
+
if (this.ownsCanvas) this.canvas.remove();
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
// src/konfetti.ts
|
|
561
|
+
var FRAME_MS = 1e3 / 60;
|
|
562
|
+
var MAX_STEP = 4;
|
|
563
|
+
var Konfetti = class {
|
|
564
|
+
constructor(canvas, createOptions = {}) {
|
|
565
|
+
this.pool = new ParticlePool();
|
|
566
|
+
this.renderer = null;
|
|
567
|
+
this.frameId = null;
|
|
568
|
+
/** Timestamp of the previous frame, or when the loop was scheduled */
|
|
569
|
+
this.lastTime = 0;
|
|
570
|
+
this.paused = false;
|
|
571
|
+
this.destroyed = false;
|
|
572
|
+
this.resizeWithWindow = false;
|
|
573
|
+
this.timeouts = /* @__PURE__ */ new Set();
|
|
574
|
+
this.intervals = /* @__PURE__ */ new Set();
|
|
575
|
+
this.handleResize = () => {
|
|
576
|
+
this.renderer?.resize();
|
|
577
|
+
};
|
|
578
|
+
this.handleVisibilityChange = () => {
|
|
579
|
+
if (document.hidden) {
|
|
580
|
+
this.cancelFrame();
|
|
581
|
+
} else {
|
|
582
|
+
this.scheduleFrame();
|
|
583
|
+
}
|
|
584
|
+
};
|
|
585
|
+
this.tick = (time) => {
|
|
586
|
+
this.frameId = null;
|
|
587
|
+
if (!this.renderer) return;
|
|
588
|
+
const dt = clamp((time - this.lastTime) / FRAME_MS, 0, MAX_STEP);
|
|
589
|
+
this.lastTime = time;
|
|
590
|
+
const hasParticles = updateParticles(this.pool, dt);
|
|
591
|
+
this.renderer.render(this.pool);
|
|
592
|
+
if (hasParticles) {
|
|
593
|
+
this.frameId = requestAnimationFrame(this.tick);
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
if (!isBrowser()) {
|
|
597
|
+
this.destroyed = true;
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
this.renderer = new Renderer(canvas);
|
|
601
|
+
this.resizeWithWindow = createOptions.resize !== false;
|
|
602
|
+
if (this.resizeWithWindow) window.addEventListener("resize", this.handleResize);
|
|
603
|
+
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Fire one burst. Skipped while the page is hidden, after destroy(), and for
|
|
607
|
+
* visitors who prefer reduced motion (unless disableForReducedMotion is false).
|
|
608
|
+
*/
|
|
609
|
+
fire(options) {
|
|
610
|
+
if (this.destroyed || !this.renderer || document.hidden) return;
|
|
611
|
+
const resolved = resolveOptions(options);
|
|
612
|
+
if (resolved.disableForReducedMotion && prefersReducedMotion()) return;
|
|
613
|
+
this.renderer.setZIndex(resolved.zIndex);
|
|
614
|
+
const { width, height } = this.renderer.getDimensions();
|
|
615
|
+
this.pool.spawn(resolved, width, height);
|
|
616
|
+
this.scheduleFrame();
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Fire a built-in preset; options override the preset's own values
|
|
620
|
+
*/
|
|
621
|
+
preset(name, options) {
|
|
622
|
+
if (!isPresetName(name) || this.destroyed) return;
|
|
623
|
+
for (const burst of PRESETS[name].bursts) {
|
|
624
|
+
const merged = mergeOptions(burst.options, options);
|
|
625
|
+
if (burst.delay) {
|
|
626
|
+
const id = setTimeout(() => {
|
|
627
|
+
this.timeouts.delete(id);
|
|
628
|
+
this.fire(merged);
|
|
629
|
+
}, burst.delay);
|
|
630
|
+
this.timeouts.add(id);
|
|
631
|
+
} else {
|
|
632
|
+
this.fire(merged);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Fire a small burst every `interval` ms until the returned function is
|
|
638
|
+
* called, or until reset() or destroy()
|
|
639
|
+
*/
|
|
640
|
+
continuous(options, interval = 250) {
|
|
641
|
+
if (this.destroyed) return () => {
|
|
642
|
+
};
|
|
643
|
+
const merged = mergeOptions(CONTINUOUS_DEFAULTS, options);
|
|
644
|
+
const delay = Number.isFinite(interval) && interval >= 16 ? interval : 250;
|
|
645
|
+
const id = setInterval(() => this.fire(merged), delay);
|
|
646
|
+
this.intervals.add(id);
|
|
647
|
+
return () => {
|
|
648
|
+
clearInterval(id);
|
|
649
|
+
this.intervals.delete(id);
|
|
650
|
+
};
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Pause the animation; particles stay where they are
|
|
654
|
+
*/
|
|
655
|
+
pause() {
|
|
656
|
+
this.paused = true;
|
|
657
|
+
this.cancelFrame();
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Resume after pause()
|
|
661
|
+
*/
|
|
662
|
+
resume() {
|
|
663
|
+
this.paused = false;
|
|
664
|
+
this.scheduleFrame();
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Clear all particles, cancel scheduled bursts and streams, stop animating and
|
|
668
|
+
* undo pause()
|
|
669
|
+
*/
|
|
670
|
+
reset() {
|
|
671
|
+
this.paused = false;
|
|
672
|
+
this.cancelFrame();
|
|
673
|
+
for (const id of this.timeouts) clearTimeout(id);
|
|
674
|
+
for (const id of this.intervals) clearInterval(id);
|
|
675
|
+
this.timeouts.clear();
|
|
676
|
+
this.intervals.clear();
|
|
677
|
+
this.pool.reset();
|
|
678
|
+
this.renderer?.clear();
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Reset, remove listeners and remove the canvas if this instance created it.
|
|
682
|
+
* The instance ignores every call afterwards.
|
|
683
|
+
*/
|
|
684
|
+
destroy() {
|
|
685
|
+
if (this.destroyed) return;
|
|
686
|
+
this.reset();
|
|
687
|
+
this.destroyed = true;
|
|
688
|
+
if (this.resizeWithWindow) window.removeEventListener("resize", this.handleResize);
|
|
689
|
+
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
|
690
|
+
this.renderer?.destroy();
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Start the loop if there is something to animate and nothing prevents it
|
|
694
|
+
*/
|
|
695
|
+
scheduleFrame() {
|
|
696
|
+
if (this.frameId !== null || this.paused || this.destroyed || document.hidden || this.pool.activeCount === 0) {
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
this.lastTime = performance.now();
|
|
700
|
+
this.frameId = requestAnimationFrame(this.tick);
|
|
701
|
+
}
|
|
702
|
+
cancelFrame() {
|
|
703
|
+
if (this.frameId !== null) {
|
|
704
|
+
cancelAnimationFrame(this.frameId);
|
|
705
|
+
this.frameId = null;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// src/global.ts
|
|
711
|
+
var globalInstance = null;
|
|
712
|
+
function getGlobalInstance() {
|
|
713
|
+
if (!isBrowser()) return null;
|
|
714
|
+
globalInstance ?? (globalInstance = new Konfetti());
|
|
715
|
+
return globalInstance;
|
|
716
|
+
}
|
|
717
|
+
function fire(options) {
|
|
718
|
+
getGlobalInstance()?.fire(options);
|
|
719
|
+
}
|
|
720
|
+
function firePreset(name, options) {
|
|
721
|
+
getGlobalInstance()?.preset(name, options);
|
|
722
|
+
}
|
|
723
|
+
function originFromElement(element) {
|
|
724
|
+
const rect = element.getBoundingClientRect();
|
|
725
|
+
return {
|
|
726
|
+
x: (rect.left + rect.width / 2) / window.innerWidth,
|
|
727
|
+
y: (rect.top + rect.height / 2) / window.innerHeight
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
function fireFromElement(element, options = {}) {
|
|
731
|
+
if (!isBrowser()) return;
|
|
732
|
+
const { preset: name, ...rest } = options;
|
|
733
|
+
const preset = isPresetName(name) ? name : void 0;
|
|
734
|
+
const followsOrigin = preset ? PRESETS[preset].followsOrigin : true;
|
|
735
|
+
const merged = followsOrigin ? { ...rest, origin: mergeOrigin(originFromElement(element), rest.origin) } : rest;
|
|
736
|
+
if (preset) {
|
|
737
|
+
firePreset(preset, merged);
|
|
738
|
+
} else {
|
|
739
|
+
fire(merged);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
function reset() {
|
|
743
|
+
globalInstance?.reset();
|
|
744
|
+
}
|
|
745
|
+
function destroy() {
|
|
746
|
+
globalInstance?.destroy();
|
|
747
|
+
globalInstance = null;
|
|
748
|
+
}
|
|
749
|
+
function create(canvas, createOptions) {
|
|
750
|
+
const instance = new Konfetti(canvas, createOptions);
|
|
751
|
+
const fireFn = ((options) => instance.fire(options));
|
|
752
|
+
fireFn.preset = (name, options) => instance.preset(name, options);
|
|
753
|
+
fireFn.continuous = (options, interval) => instance.continuous(options, interval);
|
|
754
|
+
fireFn.reset = () => instance.reset();
|
|
755
|
+
fireFn.destroy = () => instance.destroy();
|
|
756
|
+
return fireFn;
|
|
757
|
+
}
|
|
758
|
+
function continuous(options, interval) {
|
|
759
|
+
return getGlobalInstance()?.continuous(options, interval) ?? (() => {
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
var cannon = (options) => firePreset("cannon", options);
|
|
763
|
+
var explosion = (options) => firePreset("explosion", options);
|
|
764
|
+
var fireworks = (options) => firePreset("fireworks", options);
|
|
765
|
+
var rain = (options) => firePreset("rain", options);
|
|
766
|
+
var snow = (options) => firePreset("snow", options);
|
|
767
|
+
var sideCannons = (options) => firePreset("sideCannons", options);
|
|
768
|
+
var pride = (options) => firePreset("pride", options);
|
|
769
|
+
export {
|
|
770
|
+
Konfetti,
|
|
771
|
+
PRESET_NAMES,
|
|
772
|
+
cannon,
|
|
773
|
+
continuous,
|
|
774
|
+
create,
|
|
775
|
+
destroy,
|
|
776
|
+
explosion,
|
|
777
|
+
fire,
|
|
778
|
+
fireFromElement,
|
|
779
|
+
firePreset,
|
|
780
|
+
fireworks,
|
|
781
|
+
isPresetName,
|
|
782
|
+
originFromElement,
|
|
783
|
+
pride,
|
|
784
|
+
rain,
|
|
785
|
+
reset,
|
|
786
|
+
sideCannons,
|
|
787
|
+
snow
|
|
788
|
+
};
|
|
789
|
+
//# sourceMappingURL=index.js.map
|