@konfetti-js/core 0.3.0 → 0.4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @konfetti-js/core
2
2
 
3
- A lightweight, zero-dependency confetti animation library for the web. About 4 kB gzipped, TypeScript-first, and safe to import during server rendering.
3
+ A lightweight, zero-dependency confetti animation library for the web. Pieces behave like paper: they tumble in 3D, catch the light and float down. About 5 kB gzipped, TypeScript-first, and safe to import during server rendering.
4
4
 
5
5
  **[Live demo and docs](https://andreabettich.github.io/konfetti.js/)**
6
6
 
package/dist/index.cjs CHANGED
@@ -88,7 +88,7 @@ var DEFAULT_OPTIONS = {
88
88
  decay: 0.9,
89
89
  gravity: 1,
90
90
  drift: 0,
91
- ticks: 200,
91
+ ticks: 600,
92
92
  origin: { x: 0.5, y: 0.5 },
93
93
  colors: DEFAULT_COLORS,
94
94
  shapes: ["circle", "square"],
@@ -146,6 +146,9 @@ function degToRad(deg) {
146
146
  function randomRange(min, max) {
147
147
  return Math.random() * (max - min) + min;
148
148
  }
149
+ function randomSign() {
150
+ return Math.random() < 0.5 ? -1 : 1;
151
+ }
149
152
  function randomItem(arr) {
150
153
  return arr[Math.floor(Math.random() * arr.length)];
151
154
  }
@@ -174,11 +177,11 @@ var ParticlePool = class {
174
177
  */
175
178
  this.palette = [];
176
179
  this.colorIds = /* @__PURE__ */ new Map();
177
- this.data = new Float32Array(initialCapacity * 16 /* SIZE */);
180
+ this.data = new Float32Array(initialCapacity * 25 /* SIZE */);
178
181
  }
179
182
  /** How many particles fit before the buffer has to grow */
180
183
  get capacity() {
181
- return this.data.length / 16 /* SIZE */;
184
+ return this.data.length / 25 /* SIZE */;
182
185
  }
183
186
  /**
184
187
  * Remove every particle
@@ -200,17 +203,19 @@ var ParticlePool = class {
200
203
  const angleRad = degToRad(options.angle);
201
204
  const spreadRad = degToRad(options.spread);
202
205
  for (let i = 0; i < count; i++) {
203
- const idx = (this.activeCount + i) * 16 /* SIZE */;
206
+ const idx = (this.activeCount + i) * 25 /* SIZE */;
204
207
  const particleAngle = angleRad + randomRange(-spreadRad / 2, spreadRad / 2);
205
208
  const velocity = options.startVelocity * randomRange(0.5, 1);
206
209
  data[idx + 0 /* X */] = originX;
207
210
  data[idx + 1 /* Y */] = originY;
208
211
  data[idx + 2 /* VelocityX */] = Math.cos(particleAngle) * velocity;
209
212
  data[idx + 3 /* VelocityY */] = -Math.sin(particleAngle) * velocity;
210
- data[idx + 4 /* Rotation */] = randomRange(0, Math.PI * 2);
211
- data[idx + 5 /* RotationSpeed */] = randomRange(-0.1, 0.1);
212
213
  data[idx + 6 /* Tilt */] = randomRange(0, Math.PI * 2);
213
- data[idx + 7 /* TiltSpeed */] = randomRange(0.05, 0.15);
214
+ data[idx + 7 /* TiltSpeed */] = randomRange(0.08, 0.22) * randomSign();
215
+ data[idx + 16 /* Wobble */] = randomRange(0, Math.PI * 2);
216
+ data[idx + 17 /* WobbleSpeed */] = randomRange(0.02, 0.07) * randomSign();
217
+ data[idx + 4 /* Rotation */] = randomRange(0, Math.PI * 2);
218
+ data[idx + 5 /* RotationSpeed */] = randomRange(-0.04, 0.04);
214
219
  data[idx + 8 /* Color */] = this.colorId(randomItem(options.colors));
215
220
  data[idx + 9 /* Shape */] = shapeToIndex(randomItem(options.shapes));
216
221
  data[idx + 10 /* Scalar */] = options.scalar * randomRange(0.8, 1.2);
@@ -219,6 +224,8 @@ var ParticlePool = class {
219
224
  data[idx + 13 /* Drift */] = options.drift + randomRange(-0.5, 0.5);
220
225
  data[idx + 14 /* Gravity */] = options.gravity;
221
226
  data[idx + 15 /* Decay */] = options.decay;
227
+ data[idx + 18 /* AirPhase */] = randomRange(0, Math.PI * 2);
228
+ data[idx + 19 /* AirPhaseSpeed */] = randomRange(0.02, 0.05);
222
229
  }
223
230
  this.activeCount += count;
224
231
  }
@@ -229,11 +236,11 @@ var ParticlePool = class {
229
236
  const data = this.data;
230
237
  let writeIdx = 0;
231
238
  for (let readIdx = 0; readIdx < this.activeCount; readIdx++) {
232
- const srcIdx = readIdx * 16 /* SIZE */;
239
+ const srcIdx = readIdx * 25 /* SIZE */;
233
240
  if (data[srcIdx + 11 /* Life */] <= 0) continue;
234
241
  if (writeIdx !== readIdx) {
235
- const dstIdx = writeIdx * 16 /* SIZE */;
236
- data.copyWithin(dstIdx, srcIdx, srcIdx + 16 /* SIZE */);
242
+ const dstIdx = writeIdx * 25 /* SIZE */;
243
+ data.copyWithin(dstIdx, srcIdx, srcIdx + 25 /* SIZE */);
237
244
  }
238
245
  writeIdx++;
239
246
  }
@@ -254,7 +261,7 @@ var ParticlePool = class {
254
261
  const remap = /* @__PURE__ */ new Map();
255
262
  this.clearPalette();
256
263
  for (let i = 0; i < this.activeCount; i++) {
257
- const idx = i * 16 /* SIZE */ + 8 /* Color */;
264
+ const idx = i * 25 /* SIZE */ + 8 /* Color */;
258
265
  const oldId = data[idx];
259
266
  let newId = remap.get(oldId);
260
267
  if (newId === void 0) {
@@ -281,36 +288,105 @@ var ParticlePool = class {
281
288
  if (needed <= this.capacity) return;
282
289
  let capacity = this.capacity;
283
290
  while (capacity < needed) capacity *= 2;
284
- const grown = new Float32Array(Math.min(capacity, MAX_PARTICLES) * 16 /* SIZE */);
285
- grown.set(this.data.subarray(0, this.activeCount * 16 /* SIZE */));
291
+ const grown = new Float32Array(Math.min(capacity, MAX_PARTICLES) * 25 /* SIZE */);
292
+ grown.set(this.data.subarray(0, this.activeCount * 25 /* SIZE */));
286
293
  this.data = grown;
287
294
  }
288
295
  };
289
296
 
297
+ // src/orientation.ts
298
+ var LIGHT_X = -0.3;
299
+ var LIGHT_Y = -0.6;
300
+ var LIGHT_Z = 0.742;
301
+ function lightness(m) {
302
+ const facing = m[8] < 0 ? -1 : 1;
303
+ return Math.max(0, facing * (m[6] * LIGHT_X + m[7] * LIGHT_Y + m[8] * LIGHT_Z));
304
+ }
305
+ function createOrientation() {
306
+ return new Float64Array(9);
307
+ }
308
+ function orient(data, idx, out) {
309
+ const tilt = data[idx + 6 /* Tilt */];
310
+ const wobble = data[idx + 16 /* Wobble */];
311
+ const rotation = data[idx + 4 /* Rotation */];
312
+ const cx = Math.cos(tilt);
313
+ const sx = Math.sin(tilt);
314
+ const cy = Math.cos(wobble);
315
+ const sy = Math.sin(wobble);
316
+ const cz = Math.cos(rotation);
317
+ const sz = Math.sin(rotation);
318
+ out[0] = cz * cy;
319
+ out[1] = sz * cy;
320
+ out[2] = -sy;
321
+ out[3] = cz * sy * sx - sz * cx;
322
+ out[4] = sz * sy * sx + cz * cx;
323
+ out[5] = cy * sx;
324
+ out[6] = cz * sy * cx + sz * sx;
325
+ out[7] = sz * sy * cx - cz * sx;
326
+ out[8] = cy * cx;
327
+ return out;
328
+ }
329
+
290
330
  // src/physics.ts
291
- function updateParticles(pool, dt) {
331
+ var PAPER_GRAVITY = 0.45;
332
+ var FALL_DRAG = 0.0475;
333
+ var FLUTTER_SPEED = 4;
334
+ var GLIDE = 0.6;
335
+ var AIR_SWAY = 1;
336
+ var OFFSCREEN_MARGIN = 60;
337
+ var orientation = createOrientation();
338
+ function updateParticles(pool, dt, width, height) {
292
339
  const data = pool.data;
293
340
  for (let i = 0; i < pool.activeCount; i++) {
294
- const idx = i * 16 /* SIZE */;
295
- let vx = data[idx + 2 /* VelocityX */];
296
- let vy = data[idx + 3 /* VelocityY */];
297
- const tilt = data[idx + 6 /* Tilt */];
298
- vy += data[idx + 14 /* Gravity */] * dt;
299
- vx += data[idx + 13 /* Drift */] * Math.sin(tilt) * dt;
300
- const damping = data[idx + 15 /* Decay */] ** dt;
301
- vx *= damping;
302
- vy *= damping;
303
- data[idx + 2 /* VelocityX */] = vx;
304
- data[idx + 3 /* VelocityY */] = vy;
305
- data[idx + 0 /* X */] += vx * dt;
306
- data[idx + 1 /* Y */] += vy * dt;
307
- data[idx + 4 /* Rotation */] += data[idx + 5 /* RotationSpeed */] * dt;
308
- data[idx + 6 /* Tilt */] = tilt + data[idx + 7 /* TiltSpeed */] * dt;
309
- data[idx + 11 /* Life */] -= dt;
341
+ updateParticle(data, i * 25 /* SIZE */, dt, width, height);
310
342
  }
311
343
  pool.compact();
312
344
  return pool.activeCount > 0;
313
345
  }
346
+ function updateParticle(data, idx, dt, width, height) {
347
+ let vx = data[idx + 2 /* VelocityX */];
348
+ let vy = data[idx + 3 /* VelocityY */];
349
+ const m = orient(data, idx, orientation);
350
+ const nx = m[6];
351
+ const ny = m[7];
352
+ data[idx + 20 /* AxisXX */] = m[0];
353
+ data[idx + 21 /* AxisXY */] = m[1];
354
+ data[idx + 22 /* AxisYX */] = m[3];
355
+ data[idx + 23 /* AxisYY */] = m[4];
356
+ data[idx + 24 /* Light */] = lightness(m);
357
+ vy += data[idx + 14 /* Gravity */] * PAPER_GRAVITY * dt;
358
+ if (vy > 0) {
359
+ const drag = FALL_DRAG * (0.35 + 0.65 * Math.abs(ny));
360
+ vy = Math.max(0, vy - drag * vy * Math.min(vy, FLUTTER_SPEED) * dt);
361
+ vx -= GLIDE * vy * 2 * nx * ny * dt;
362
+ }
363
+ const phase = data[idx + 18 /* AirPhase */];
364
+ const airX = data[idx + 13 /* Drift */] + AIR_SWAY * Math.sin(phase);
365
+ const damping = data[idx + 15 /* Decay */] ** dt;
366
+ vx = airX + (vx - airX) * damping;
367
+ vy *= damping;
368
+ const spin = 0.6 + Math.min(1.4, Math.hypot(vx, vy) / 3);
369
+ data[idx + 2 /* VelocityX */] = vx;
370
+ data[idx + 3 /* VelocityY */] = vy;
371
+ data[idx + 0 /* X */] += vx * dt;
372
+ data[idx + 1 /* Y */] += vy * dt;
373
+ data[idx + 6 /* Tilt */] += data[idx + 7 /* TiltSpeed */] * spin * dt;
374
+ data[idx + 16 /* Wobble */] += data[idx + 17 /* WobbleSpeed */] * dt;
375
+ data[idx + 4 /* Rotation */] += data[idx + 5 /* RotationSpeed */] * dt;
376
+ data[idx + 18 /* AirPhase */] = phase + data[idx + 19 /* AirPhaseSpeed */] * dt;
377
+ data[idx + 11 /* Life */] -= dt;
378
+ if (width > 0 && height > 0) {
379
+ const x = data[idx + 0 /* X */];
380
+ const y = data[idx + 1 /* Y */];
381
+ const fellOut = y > height + OFFSCREEN_MARGIN && vy >= 0;
382
+ const leftOut = x < -OFFSCREEN_MARGIN * 2 && vx <= 0;
383
+ const rightOut = x > width + OFFSCREEN_MARGIN * 2 && vx >= 0;
384
+ const floatedOut = y < -OFFSCREEN_MARGIN && vy <= 0 && data[idx + 14 /* Gravity */] <= 0;
385
+ if (fellOut || leftOut || rightOut || floatedOut) {
386
+ data[idx + 11 /* Life */] = 0;
387
+ }
388
+ }
389
+ }
314
390
 
315
391
  // src/presets.ts
316
392
  var FIREWORK_COLORS = ["#ff0000", "#ffff00", "#00ff00", "#00ffff", "#ff00ff"];
@@ -395,7 +471,7 @@ var PRESETS = {
395
471
  gravity: 0.5,
396
472
  drift: 0,
397
473
  origin: { x: 0.5, y: 0 },
398
- ticks: 300
474
+ ticks: 900
399
475
  }
400
476
  }
401
477
  ]
@@ -414,7 +490,7 @@ var PRESETS = {
414
490
  gravity: 0.3,
415
491
  drift: 1,
416
492
  origin: { x: 0.5, y: -0.1 },
417
- ticks: 400,
493
+ ticks: 1200,
418
494
  colors: ["#ffffff", "#e0e0e0", "#c0c0c0"],
419
495
  shapes: ["circle"]
420
496
  }
@@ -474,6 +550,40 @@ function isPresetName(value) {
474
550
 
475
551
  // src/renderer.ts
476
552
  var BASE_SIZE = 10;
553
+ var SHADE_STEPS = 16;
554
+ var MIN_BRIGHTNESS = 0.55;
555
+ var MAX_BRIGHTNESS = 1.2;
556
+ var LIFETIME_FADE = 0.1;
557
+ var MAX_SHADE_CACHE = 4096;
558
+ var BLACK = [0, 0, 0, 1];
559
+ function parseColor(ctx, color) {
560
+ ctx.fillStyle = "#000000";
561
+ ctx.fillStyle = color;
562
+ const overBlack = String(ctx.fillStyle);
563
+ ctx.fillStyle = "#ffffff";
564
+ ctx.fillStyle = color;
565
+ if (String(ctx.fillStyle) !== overBlack) return null;
566
+ const hex = /^#([0-9a-f]{6})$/i.exec(overBlack);
567
+ if (hex) {
568
+ const value = Number.parseInt(hex[1], 16);
569
+ return [value >> 16, value >> 8 & 255, value & 255, 1];
570
+ }
571
+ const rgba = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(overBlack);
572
+ if (!rgba) return null;
573
+ const alpha = rgba[4] === void 0 ? 1 : Number(rgba[4]);
574
+ return [Number(rgba[1]), Number(rgba[2]), Number(rgba[3]), alpha];
575
+ }
576
+ function buildShades([r, g, b, alpha]) {
577
+ const shades = [];
578
+ for (let step = 0; step < SHADE_STEPS; step++) {
579
+ const brightness = MIN_BRIGHTNESS + (MAX_BRIGHTNESS - MIN_BRIGHTNESS) * step / (SHADE_STEPS - 1);
580
+ const [sr, sg, sb] = [r, g, b].map(
581
+ (c) => Math.round(brightness <= 1 ? c * brightness : c + (255 - c) * (brightness - 1))
582
+ );
583
+ shades.push(alpha < 1 ? `rgba(${sr}, ${sg}, ${sb}, ${alpha})` : `rgb(${sr}, ${sg}, ${sb})`);
584
+ }
585
+ return shades;
586
+ }
477
587
  function createOverlayCanvas() {
478
588
  const canvas = document.createElement("canvas");
479
589
  canvas.className = "konfetti-canvas";
@@ -496,7 +606,9 @@ var Renderer = class {
496
606
  this.width = 0;
497
607
  this.height = 0;
498
608
  this.pixelRatio = 1;
499
- /** Particle indices grouped by palette color, reused between frames */
609
+ /** Brightness steps per color */
610
+ this.shadeCache = /* @__PURE__ */ new Map();
611
+ /** Particle indices grouped by color and shade step, reused between frames */
500
612
  this.buckets = [];
501
613
  this.ownsCanvas = !canvas;
502
614
  this.canvas = canvas ?? createOverlayCanvas();
@@ -544,7 +656,10 @@ var Renderer = class {
544
656
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
545
657
  }
546
658
  /**
547
- * Draw all particles, batched by color to limit fillStyle changes
659
+ * Draw every piece as flat paper turned in 3D: the transform is the piece's
660
+ * rotation projected onto the screen (computed by physics), and its color is
661
+ * shaded by how much its visible side faces the light. Pieces are drawn
662
+ * grouped by color and shade, so the fill changes once per group.
548
663
  */
549
664
  render(pool) {
550
665
  this.clear();
@@ -553,35 +668,32 @@ var Renderer = class {
553
668
  const data = pool.data;
554
669
  const ratio = this.pixelRatio;
555
670
  const buckets = this.buckets;
556
- for (const bucket of buckets) bucket.length = 0;
671
+ for (const bucket of buckets) if (bucket) bucket.length = 0;
557
672
  for (let i = 0; i < pool.activeCount; i++) {
558
- const colorId = data[i * 16 /* SIZE */ + 8 /* Color */];
559
- if (!buckets[colorId]) buckets[colorId] = [];
560
- buckets[colorId].push(i);
673
+ const idx = i * 25 /* SIZE */;
674
+ const step = Math.round(data[idx + 24 /* Light */] * (SHADE_STEPS - 1));
675
+ const key = data[idx + 8 /* Color */] * SHADE_STEPS + step;
676
+ if (!buckets[key]) buckets[key] = [];
677
+ buckets[key].push(idx);
561
678
  }
562
- for (let colorId = 0; colorId < buckets.length; colorId++) {
563
- const bucket = buckets[colorId];
679
+ for (let key = 0; key < buckets.length; key++) {
680
+ const bucket = buckets[key];
564
681
  if (!bucket || bucket.length === 0) continue;
565
- ctx.fillStyle = pool.palette[colorId];
566
- for (const particle of bucket) {
567
- const idx = particle * 16 /* SIZE */;
568
- const tilt = data[idx + 6 /* Tilt */];
569
- const rotation = data[idx + 4 /* Rotation */];
682
+ const color = pool.palette[Math.floor(key / SHADE_STEPS)];
683
+ ctx.fillStyle = this.shadesFor(ctx, color)[key % SHADE_STEPS];
684
+ for (const idx of bucket) {
570
685
  const opacity = Math.min(
571
686
  1,
572
- data[idx + 11 /* Life */] / data[idx + 12 /* MaxLife */] * 2
687
+ data[idx + 11 /* Life */] / (data[idx + 12 /* MaxLife */] * LIFETIME_FADE)
573
688
  );
574
- const size = BASE_SIZE * data[idx + 10 /* Scalar */] * (0.8 + 0.2 * Math.cos(tilt));
689
+ const size = BASE_SIZE * data[idx + 10 /* Scalar */];
575
690
  if (size < 0.5 || opacity < 0.01) continue;
576
- const cos = Math.cos(rotation);
577
- const sin = Math.sin(rotation);
578
- const squash = 0.6 + 0.4 * Math.abs(Math.cos(tilt));
579
691
  ctx.globalAlpha = opacity;
580
692
  ctx.setTransform(
581
- ratio * cos,
582
- ratio * sin,
583
- -ratio * sin * squash,
584
- ratio * cos * squash,
693
+ ratio * data[idx + 20 /* AxisXX */],
694
+ ratio * data[idx + 21 /* AxisXY */],
695
+ ratio * data[idx + 22 /* AxisYX */],
696
+ ratio * data[idx + 23 /* AxisYY */],
585
697
  ratio * data[idx + 0 /* X */],
586
698
  ratio * data[idx + 1 /* Y */]
587
699
  );
@@ -591,6 +703,15 @@ var Renderer = class {
591
703
  ctx.globalAlpha = 1;
592
704
  ctx.setTransform(1, 0, 0, 1, 0, 0);
593
705
  }
706
+ shadesFor(ctx, color) {
707
+ let shades = this.shadeCache.get(color);
708
+ if (!shades) {
709
+ if (this.shadeCache.size >= MAX_SHADE_CACHE) this.shadeCache.clear();
710
+ shades = buildShades(parseColor(ctx, color) ?? BLACK);
711
+ this.shadeCache.set(color, shades);
712
+ }
713
+ return shades;
714
+ }
594
715
  /**
595
716
  * Destroy renderer and remove the canvas if we created it
596
717
  */
@@ -613,6 +734,8 @@ var Konfetti = class {
613
734
  this.paused = false;
614
735
  this.destroyed = false;
615
736
  this.resizeWithWindow = false;
737
+ /** Follows a custom canvas whose size changes through layout, not just window resizes */
738
+ this.resizeObserver = null;
616
739
  this.timeouts = /* @__PURE__ */ new Set();
617
740
  this.intervals = /* @__PURE__ */ new Set();
618
741
  this.handleResize = () => {
@@ -630,7 +753,8 @@ var Konfetti = class {
630
753
  if (!this.renderer) return;
631
754
  const dt = clamp((time - this.lastTime) / FRAME_MS, 0, MAX_STEP);
632
755
  this.lastTime = time;
633
- const hasParticles = updateParticles(this.pool, dt);
756
+ const { width, height } = this.renderer.getDimensions();
757
+ const hasParticles = updateParticles(this.pool, dt, width, height);
634
758
  this.renderer.render(this.pool);
635
759
  if (hasParticles) {
636
760
  this.frameId = requestAnimationFrame(this.tick);
@@ -642,7 +766,13 @@ var Konfetti = class {
642
766
  }
643
767
  this.renderer = new Renderer(canvas);
644
768
  this.resizeWithWindow = createOptions.resize !== false;
645
- if (this.resizeWithWindow) window.addEventListener("resize", this.handleResize);
769
+ if (this.resizeWithWindow) {
770
+ window.addEventListener("resize", this.handleResize);
771
+ if (canvas && typeof ResizeObserver !== "undefined") {
772
+ this.resizeObserver = new ResizeObserver(this.handleResize);
773
+ this.resizeObserver.observe(canvas);
774
+ }
775
+ }
646
776
  document.addEventListener("visibilitychange", this.handleVisibilityChange);
647
777
  }
648
778
  /**
@@ -729,6 +859,8 @@ var Konfetti = class {
729
859
  this.reset();
730
860
  this.destroyed = true;
731
861
  if (this.resizeWithWindow) window.removeEventListener("resize", this.handleResize);
862
+ this.resizeObserver?.disconnect();
863
+ this.resizeObserver = null;
732
864
  document.removeEventListener("visibilitychange", this.handleVisibilityChange);
733
865
  this.renderer?.destroy();
734
866
  }