@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/dist/index.js CHANGED
@@ -45,7 +45,7 @@ var DEFAULT_OPTIONS = {
45
45
  decay: 0.9,
46
46
  gravity: 1,
47
47
  drift: 0,
48
- ticks: 200,
48
+ ticks: 600,
49
49
  origin: { x: 0.5, y: 0.5 },
50
50
  colors: DEFAULT_COLORS,
51
51
  shapes: ["circle", "square"],
@@ -103,6 +103,9 @@ function degToRad(deg) {
103
103
  function randomRange(min, max) {
104
104
  return Math.random() * (max - min) + min;
105
105
  }
106
+ function randomSign() {
107
+ return Math.random() < 0.5 ? -1 : 1;
108
+ }
106
109
  function randomItem(arr) {
107
110
  return arr[Math.floor(Math.random() * arr.length)];
108
111
  }
@@ -131,11 +134,11 @@ var ParticlePool = class {
131
134
  */
132
135
  this.palette = [];
133
136
  this.colorIds = /* @__PURE__ */ new Map();
134
- this.data = new Float32Array(initialCapacity * 16 /* SIZE */);
137
+ this.data = new Float32Array(initialCapacity * 25 /* SIZE */);
135
138
  }
136
139
  /** How many particles fit before the buffer has to grow */
137
140
  get capacity() {
138
- return this.data.length / 16 /* SIZE */;
141
+ return this.data.length / 25 /* SIZE */;
139
142
  }
140
143
  /**
141
144
  * Remove every particle
@@ -157,17 +160,19 @@ var ParticlePool = class {
157
160
  const angleRad = degToRad(options.angle);
158
161
  const spreadRad = degToRad(options.spread);
159
162
  for (let i = 0; i < count; i++) {
160
- const idx = (this.activeCount + i) * 16 /* SIZE */;
163
+ const idx = (this.activeCount + i) * 25 /* SIZE */;
161
164
  const particleAngle = angleRad + randomRange(-spreadRad / 2, spreadRad / 2);
162
165
  const velocity = options.startVelocity * randomRange(0.5, 1);
163
166
  data[idx + 0 /* X */] = originX;
164
167
  data[idx + 1 /* Y */] = originY;
165
168
  data[idx + 2 /* VelocityX */] = Math.cos(particleAngle) * velocity;
166
169
  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
170
  data[idx + 6 /* Tilt */] = randomRange(0, Math.PI * 2);
170
- data[idx + 7 /* TiltSpeed */] = randomRange(0.05, 0.15);
171
+ data[idx + 7 /* TiltSpeed */] = randomRange(0.08, 0.22) * randomSign();
172
+ data[idx + 16 /* Wobble */] = randomRange(0, Math.PI * 2);
173
+ data[idx + 17 /* WobbleSpeed */] = randomRange(0.02, 0.07) * randomSign();
174
+ data[idx + 4 /* Rotation */] = randomRange(0, Math.PI * 2);
175
+ data[idx + 5 /* RotationSpeed */] = randomRange(-0.04, 0.04);
171
176
  data[idx + 8 /* Color */] = this.colorId(randomItem(options.colors));
172
177
  data[idx + 9 /* Shape */] = shapeToIndex(randomItem(options.shapes));
173
178
  data[idx + 10 /* Scalar */] = options.scalar * randomRange(0.8, 1.2);
@@ -176,6 +181,8 @@ var ParticlePool = class {
176
181
  data[idx + 13 /* Drift */] = options.drift + randomRange(-0.5, 0.5);
177
182
  data[idx + 14 /* Gravity */] = options.gravity;
178
183
  data[idx + 15 /* Decay */] = options.decay;
184
+ data[idx + 18 /* AirPhase */] = randomRange(0, Math.PI * 2);
185
+ data[idx + 19 /* AirPhaseSpeed */] = randomRange(0.02, 0.05);
179
186
  }
180
187
  this.activeCount += count;
181
188
  }
@@ -186,11 +193,11 @@ var ParticlePool = class {
186
193
  const data = this.data;
187
194
  let writeIdx = 0;
188
195
  for (let readIdx = 0; readIdx < this.activeCount; readIdx++) {
189
- const srcIdx = readIdx * 16 /* SIZE */;
196
+ const srcIdx = readIdx * 25 /* SIZE */;
190
197
  if (data[srcIdx + 11 /* Life */] <= 0) continue;
191
198
  if (writeIdx !== readIdx) {
192
- const dstIdx = writeIdx * 16 /* SIZE */;
193
- data.copyWithin(dstIdx, srcIdx, srcIdx + 16 /* SIZE */);
199
+ const dstIdx = writeIdx * 25 /* SIZE */;
200
+ data.copyWithin(dstIdx, srcIdx, srcIdx + 25 /* SIZE */);
194
201
  }
195
202
  writeIdx++;
196
203
  }
@@ -211,7 +218,7 @@ var ParticlePool = class {
211
218
  const remap = /* @__PURE__ */ new Map();
212
219
  this.clearPalette();
213
220
  for (let i = 0; i < this.activeCount; i++) {
214
- const idx = i * 16 /* SIZE */ + 8 /* Color */;
221
+ const idx = i * 25 /* SIZE */ + 8 /* Color */;
215
222
  const oldId = data[idx];
216
223
  let newId = remap.get(oldId);
217
224
  if (newId === void 0) {
@@ -238,36 +245,105 @@ var ParticlePool = class {
238
245
  if (needed <= this.capacity) return;
239
246
  let capacity = this.capacity;
240
247
  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 */));
248
+ const grown = new Float32Array(Math.min(capacity, MAX_PARTICLES) * 25 /* SIZE */);
249
+ grown.set(this.data.subarray(0, this.activeCount * 25 /* SIZE */));
243
250
  this.data = grown;
244
251
  }
245
252
  };
246
253
 
254
+ // src/orientation.ts
255
+ var LIGHT_X = -0.3;
256
+ var LIGHT_Y = -0.6;
257
+ var LIGHT_Z = 0.742;
258
+ function lightness(m) {
259
+ const facing = m[8] < 0 ? -1 : 1;
260
+ return Math.max(0, facing * (m[6] * LIGHT_X + m[7] * LIGHT_Y + m[8] * LIGHT_Z));
261
+ }
262
+ function createOrientation() {
263
+ return new Float64Array(9);
264
+ }
265
+ function orient(data, idx, out) {
266
+ const tilt = data[idx + 6 /* Tilt */];
267
+ const wobble = data[idx + 16 /* Wobble */];
268
+ const rotation = data[idx + 4 /* Rotation */];
269
+ const cx = Math.cos(tilt);
270
+ const sx = Math.sin(tilt);
271
+ const cy = Math.cos(wobble);
272
+ const sy = Math.sin(wobble);
273
+ const cz = Math.cos(rotation);
274
+ const sz = Math.sin(rotation);
275
+ out[0] = cz * cy;
276
+ out[1] = sz * cy;
277
+ out[2] = -sy;
278
+ out[3] = cz * sy * sx - sz * cx;
279
+ out[4] = sz * sy * sx + cz * cx;
280
+ out[5] = cy * sx;
281
+ out[6] = cz * sy * cx + sz * sx;
282
+ out[7] = sz * sy * cx - cz * sx;
283
+ out[8] = cy * cx;
284
+ return out;
285
+ }
286
+
247
287
  // src/physics.ts
248
- function updateParticles(pool, dt) {
288
+ var PAPER_GRAVITY = 0.45;
289
+ var FALL_DRAG = 0.0475;
290
+ var FLUTTER_SPEED = 4;
291
+ var GLIDE = 0.6;
292
+ var AIR_SWAY = 1;
293
+ var OFFSCREEN_MARGIN = 60;
294
+ var orientation = createOrientation();
295
+ function updateParticles(pool, dt, width, height) {
249
296
  const data = pool.data;
250
297
  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;
298
+ updateParticle(data, i * 25 /* SIZE */, dt, width, height);
267
299
  }
268
300
  pool.compact();
269
301
  return pool.activeCount > 0;
270
302
  }
303
+ function updateParticle(data, idx, dt, width, height) {
304
+ let vx = data[idx + 2 /* VelocityX */];
305
+ let vy = data[idx + 3 /* VelocityY */];
306
+ const m = orient(data, idx, orientation);
307
+ const nx = m[6];
308
+ const ny = m[7];
309
+ data[idx + 20 /* AxisXX */] = m[0];
310
+ data[idx + 21 /* AxisXY */] = m[1];
311
+ data[idx + 22 /* AxisYX */] = m[3];
312
+ data[idx + 23 /* AxisYY */] = m[4];
313
+ data[idx + 24 /* Light */] = lightness(m);
314
+ vy += data[idx + 14 /* Gravity */] * PAPER_GRAVITY * dt;
315
+ if (vy > 0) {
316
+ const drag = FALL_DRAG * (0.35 + 0.65 * Math.abs(ny));
317
+ vy = Math.max(0, vy - drag * vy * Math.min(vy, FLUTTER_SPEED) * dt);
318
+ vx -= GLIDE * vy * 2 * nx * ny * dt;
319
+ }
320
+ const phase = data[idx + 18 /* AirPhase */];
321
+ const airX = data[idx + 13 /* Drift */] + AIR_SWAY * Math.sin(phase);
322
+ const damping = data[idx + 15 /* Decay */] ** dt;
323
+ vx = airX + (vx - airX) * damping;
324
+ vy *= damping;
325
+ const spin = 0.6 + Math.min(1.4, Math.hypot(vx, vy) / 3);
326
+ data[idx + 2 /* VelocityX */] = vx;
327
+ data[idx + 3 /* VelocityY */] = vy;
328
+ data[idx + 0 /* X */] += vx * dt;
329
+ data[idx + 1 /* Y */] += vy * dt;
330
+ data[idx + 6 /* Tilt */] += data[idx + 7 /* TiltSpeed */] * spin * dt;
331
+ data[idx + 16 /* Wobble */] += data[idx + 17 /* WobbleSpeed */] * dt;
332
+ data[idx + 4 /* Rotation */] += data[idx + 5 /* RotationSpeed */] * dt;
333
+ data[idx + 18 /* AirPhase */] = phase + data[idx + 19 /* AirPhaseSpeed */] * dt;
334
+ data[idx + 11 /* Life */] -= dt;
335
+ if (width > 0 && height > 0) {
336
+ const x = data[idx + 0 /* X */];
337
+ const y = data[idx + 1 /* Y */];
338
+ const fellOut = y > height + OFFSCREEN_MARGIN && vy >= 0;
339
+ const leftOut = x < -OFFSCREEN_MARGIN * 2 && vx <= 0;
340
+ const rightOut = x > width + OFFSCREEN_MARGIN * 2 && vx >= 0;
341
+ const floatedOut = y < -OFFSCREEN_MARGIN && vy <= 0 && data[idx + 14 /* Gravity */] <= 0;
342
+ if (fellOut || leftOut || rightOut || floatedOut) {
343
+ data[idx + 11 /* Life */] = 0;
344
+ }
345
+ }
346
+ }
271
347
 
272
348
  // src/presets.ts
273
349
  var FIREWORK_COLORS = ["#ff0000", "#ffff00", "#00ff00", "#00ffff", "#ff00ff"];
@@ -352,7 +428,7 @@ var PRESETS = {
352
428
  gravity: 0.5,
353
429
  drift: 0,
354
430
  origin: { x: 0.5, y: 0 },
355
- ticks: 300
431
+ ticks: 900
356
432
  }
357
433
  }
358
434
  ]
@@ -371,7 +447,7 @@ var PRESETS = {
371
447
  gravity: 0.3,
372
448
  drift: 1,
373
449
  origin: { x: 0.5, y: -0.1 },
374
- ticks: 400,
450
+ ticks: 1200,
375
451
  colors: ["#ffffff", "#e0e0e0", "#c0c0c0"],
376
452
  shapes: ["circle"]
377
453
  }
@@ -431,6 +507,40 @@ function isPresetName(value) {
431
507
 
432
508
  // src/renderer.ts
433
509
  var BASE_SIZE = 10;
510
+ var SHADE_STEPS = 16;
511
+ var MIN_BRIGHTNESS = 0.55;
512
+ var MAX_BRIGHTNESS = 1.2;
513
+ var LIFETIME_FADE = 0.1;
514
+ var MAX_SHADE_CACHE = 4096;
515
+ var BLACK = [0, 0, 0, 1];
516
+ function parseColor(ctx, color) {
517
+ ctx.fillStyle = "#000000";
518
+ ctx.fillStyle = color;
519
+ const overBlack = String(ctx.fillStyle);
520
+ ctx.fillStyle = "#ffffff";
521
+ ctx.fillStyle = color;
522
+ if (String(ctx.fillStyle) !== overBlack) return null;
523
+ const hex = /^#([0-9a-f]{6})$/i.exec(overBlack);
524
+ if (hex) {
525
+ const value = Number.parseInt(hex[1], 16);
526
+ return [value >> 16, value >> 8 & 255, value & 255, 1];
527
+ }
528
+ const rgba = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(overBlack);
529
+ if (!rgba) return null;
530
+ const alpha = rgba[4] === void 0 ? 1 : Number(rgba[4]);
531
+ return [Number(rgba[1]), Number(rgba[2]), Number(rgba[3]), alpha];
532
+ }
533
+ function buildShades([r, g, b, alpha]) {
534
+ const shades = [];
535
+ for (let step = 0; step < SHADE_STEPS; step++) {
536
+ const brightness = MIN_BRIGHTNESS + (MAX_BRIGHTNESS - MIN_BRIGHTNESS) * step / (SHADE_STEPS - 1);
537
+ const [sr, sg, sb] = [r, g, b].map(
538
+ (c) => Math.round(brightness <= 1 ? c * brightness : c + (255 - c) * (brightness - 1))
539
+ );
540
+ shades.push(alpha < 1 ? `rgba(${sr}, ${sg}, ${sb}, ${alpha})` : `rgb(${sr}, ${sg}, ${sb})`);
541
+ }
542
+ return shades;
543
+ }
434
544
  function createOverlayCanvas() {
435
545
  const canvas = document.createElement("canvas");
436
546
  canvas.className = "konfetti-canvas";
@@ -453,7 +563,9 @@ var Renderer = class {
453
563
  this.width = 0;
454
564
  this.height = 0;
455
565
  this.pixelRatio = 1;
456
- /** Particle indices grouped by palette color, reused between frames */
566
+ /** Brightness steps per color */
567
+ this.shadeCache = /* @__PURE__ */ new Map();
568
+ /** Particle indices grouped by color and shade step, reused between frames */
457
569
  this.buckets = [];
458
570
  this.ownsCanvas = !canvas;
459
571
  this.canvas = canvas ?? createOverlayCanvas();
@@ -501,7 +613,10 @@ var Renderer = class {
501
613
  this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
502
614
  }
503
615
  /**
504
- * Draw all particles, batched by color to limit fillStyle changes
616
+ * Draw every piece as flat paper turned in 3D: the transform is the piece's
617
+ * rotation projected onto the screen (computed by physics), and its color is
618
+ * shaded by how much its visible side faces the light. Pieces are drawn
619
+ * grouped by color and shade, so the fill changes once per group.
505
620
  */
506
621
  render(pool) {
507
622
  this.clear();
@@ -510,35 +625,32 @@ var Renderer = class {
510
625
  const data = pool.data;
511
626
  const ratio = this.pixelRatio;
512
627
  const buckets = this.buckets;
513
- for (const bucket of buckets) bucket.length = 0;
628
+ for (const bucket of buckets) if (bucket) bucket.length = 0;
514
629
  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);
630
+ const idx = i * 25 /* SIZE */;
631
+ const step = Math.round(data[idx + 24 /* Light */] * (SHADE_STEPS - 1));
632
+ const key = data[idx + 8 /* Color */] * SHADE_STEPS + step;
633
+ if (!buckets[key]) buckets[key] = [];
634
+ buckets[key].push(idx);
518
635
  }
519
- for (let colorId = 0; colorId < buckets.length; colorId++) {
520
- const bucket = buckets[colorId];
636
+ for (let key = 0; key < buckets.length; key++) {
637
+ const bucket = buckets[key];
521
638
  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 */];
639
+ const color = pool.palette[Math.floor(key / SHADE_STEPS)];
640
+ ctx.fillStyle = this.shadesFor(ctx, color)[key % SHADE_STEPS];
641
+ for (const idx of bucket) {
527
642
  const opacity = Math.min(
528
643
  1,
529
- data[idx + 11 /* Life */] / data[idx + 12 /* MaxLife */] * 2
644
+ data[idx + 11 /* Life */] / (data[idx + 12 /* MaxLife */] * LIFETIME_FADE)
530
645
  );
531
- const size = BASE_SIZE * data[idx + 10 /* Scalar */] * (0.8 + 0.2 * Math.cos(tilt));
646
+ const size = BASE_SIZE * data[idx + 10 /* Scalar */];
532
647
  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
648
  ctx.globalAlpha = opacity;
537
649
  ctx.setTransform(
538
- ratio * cos,
539
- ratio * sin,
540
- -ratio * sin * squash,
541
- ratio * cos * squash,
650
+ ratio * data[idx + 20 /* AxisXX */],
651
+ ratio * data[idx + 21 /* AxisXY */],
652
+ ratio * data[idx + 22 /* AxisYX */],
653
+ ratio * data[idx + 23 /* AxisYY */],
542
654
  ratio * data[idx + 0 /* X */],
543
655
  ratio * data[idx + 1 /* Y */]
544
656
  );
@@ -548,6 +660,15 @@ var Renderer = class {
548
660
  ctx.globalAlpha = 1;
549
661
  ctx.setTransform(1, 0, 0, 1, 0, 0);
550
662
  }
663
+ shadesFor(ctx, color) {
664
+ let shades = this.shadeCache.get(color);
665
+ if (!shades) {
666
+ if (this.shadeCache.size >= MAX_SHADE_CACHE) this.shadeCache.clear();
667
+ shades = buildShades(parseColor(ctx, color) ?? BLACK);
668
+ this.shadeCache.set(color, shades);
669
+ }
670
+ return shades;
671
+ }
551
672
  /**
552
673
  * Destroy renderer and remove the canvas if we created it
553
674
  */
@@ -570,6 +691,8 @@ var Konfetti = class {
570
691
  this.paused = false;
571
692
  this.destroyed = false;
572
693
  this.resizeWithWindow = false;
694
+ /** Follows a custom canvas whose size changes through layout, not just window resizes */
695
+ this.resizeObserver = null;
573
696
  this.timeouts = /* @__PURE__ */ new Set();
574
697
  this.intervals = /* @__PURE__ */ new Set();
575
698
  this.handleResize = () => {
@@ -587,7 +710,8 @@ var Konfetti = class {
587
710
  if (!this.renderer) return;
588
711
  const dt = clamp((time - this.lastTime) / FRAME_MS, 0, MAX_STEP);
589
712
  this.lastTime = time;
590
- const hasParticles = updateParticles(this.pool, dt);
713
+ const { width, height } = this.renderer.getDimensions();
714
+ const hasParticles = updateParticles(this.pool, dt, width, height);
591
715
  this.renderer.render(this.pool);
592
716
  if (hasParticles) {
593
717
  this.frameId = requestAnimationFrame(this.tick);
@@ -599,7 +723,13 @@ var Konfetti = class {
599
723
  }
600
724
  this.renderer = new Renderer(canvas);
601
725
  this.resizeWithWindow = createOptions.resize !== false;
602
- if (this.resizeWithWindow) window.addEventListener("resize", this.handleResize);
726
+ if (this.resizeWithWindow) {
727
+ window.addEventListener("resize", this.handleResize);
728
+ if (canvas && typeof ResizeObserver !== "undefined") {
729
+ this.resizeObserver = new ResizeObserver(this.handleResize);
730
+ this.resizeObserver.observe(canvas);
731
+ }
732
+ }
603
733
  document.addEventListener("visibilitychange", this.handleVisibilityChange);
604
734
  }
605
735
  /**
@@ -686,6 +816,8 @@ var Konfetti = class {
686
816
  this.reset();
687
817
  this.destroyed = true;
688
818
  if (this.resizeWithWindow) window.removeEventListener("resize", this.handleResize);
819
+ this.resizeObserver?.disconnect();
820
+ this.resizeObserver = null;
689
821
  document.removeEventListener("visibilitychange", this.handleVisibilityChange);
690
822
  this.renderer?.destroy();
691
823
  }