falling-animation 0.1.2 → 0.1.3

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.
@@ -243,7 +243,7 @@ function getAnimation(name) {
243
243
  }
244
244
 
245
245
  /**
246
- * Particle class - manages individual falling objects
246
+ * Particle class - manages individual falling objects (Canvas-based, no DOM)
247
247
  */
248
248
  /**
249
249
  * Speed multiplier to convert user-friendly speed units to px/ms
@@ -253,22 +253,23 @@ function getAnimation(name) {
253
253
  */
254
254
  const SPEED_MULTIPLIER = 0.04; // 40 px/s per unit, divided by 1000 for ms
255
255
  class Particle {
256
- constructor(options) {
257
- this.options = options;
256
+ constructor(options, containerWidth, containerHeight) {
258
257
  this.id = generateId();
259
258
  this.age = 0;
260
259
  this.phase = Math.random() * Math.PI * 2;
261
260
  this.data = {};
262
- // Get container dimensions
263
- this.containerWidth = options.container.clientWidth;
264
- this.containerHeight = options.container.clientHeight;
261
+ // Store container dimensions
262
+ this.containerWidth = containerWidth;
263
+ this.containerHeight = containerHeight;
265
264
  // Pick random object
266
265
  const object = weightedRandomPick(options.objects);
266
+ // Store content and type for rendering
267
+ this.content = object.src || object.content || '❄️';
268
+ this.objectType = object.type;
267
269
  // Initialize position at top with random x
268
270
  this.x = randomRange(-50, this.containerWidth + 50);
269
271
  this.y = -50;
270
272
  // Initialize velocity (apply speed multiplier for intuitive units)
271
- // speed=1 → ~40px/s (1cm/s), speed=0.1 → ~4px/s (1mm/s)
272
273
  this.vy = randomFromRange(options.speed) * SPEED_MULTIPLIER;
273
274
  this.vx = options.wind * SPEED_MULTIPLIER * randomRange(0.5, 1.5);
274
275
  // Initialize size and opacity
@@ -281,8 +282,6 @@ class Particle {
281
282
  this.animation = randomPick(options.animation);
282
283
  // Initialize animation-specific data
283
284
  this.initAnimationData();
284
- // Create DOM element
285
- this.element = this.createElement(object);
286
285
  }
287
286
  /**
288
287
  * Initialize animation-specific data
@@ -294,54 +293,6 @@ class Particle {
294
293
  this.data.wobbleY = randomRange(0.005, 0.015);
295
294
  }
296
295
  }
297
- /**
298
- * Create DOM element for the particle
299
- */
300
- createElement(object) {
301
- var _a, _b, _c, _d;
302
- const element = document.createElement('div');
303
- element.className = 'falling-particle';
304
- element.setAttribute('data-particle-id', String(this.id));
305
- // Set content based on type
306
- switch (object.type) {
307
- case 'emoji':
308
- element.textContent = (_a = object.content) !== null && _a !== void 0 ? _a : '❄️';
309
- element.style.fontSize = `${this.size}px`;
310
- element.style.lineHeight = '1';
311
- break;
312
- case 'image':
313
- const img = document.createElement('img');
314
- img.src = (_c = (_b = object.src) !== null && _b !== void 0 ? _b : object.content) !== null && _c !== void 0 ? _c : '';
315
- img.alt = '';
316
- img.style.width = `${this.size}px`;
317
- img.style.height = `${this.size}px`;
318
- img.style.objectFit = 'contain';
319
- img.draggable = false;
320
- element.appendChild(img);
321
- break;
322
- case 'html':
323
- element.innerHTML = (_d = object.content) !== null && _d !== void 0 ? _d : '';
324
- break;
325
- }
326
- // Apply base styles
327
- Object.assign(element.style, {
328
- position: 'absolute',
329
- pointerEvents: 'none',
330
- userSelect: 'none',
331
- willChange: 'transform, opacity',
332
- opacity: String(this.opacity),
333
- left: '0',
334
- top: '0',
335
- transform: this.getTransform()
336
- });
337
- return element;
338
- }
339
- /**
340
- * Get CSS transform string
341
- */
342
- getTransform() {
343
- return `translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`;
344
- }
345
296
  /**
346
297
  * Update particle state
347
298
  */
@@ -350,8 +301,6 @@ class Particle {
350
301
  // Apply animation
351
302
  const animationFn = getAnimation(this.animation);
352
303
  animationFn(this, deltaTime, elapsed);
353
- // Update DOM
354
- this.element.style.transform = this.getTransform();
355
304
  }
356
305
  /**
357
306
  * Check if particle is out of bounds
@@ -368,16 +317,200 @@ class Particle {
368
317
  this.containerWidth = width;
369
318
  this.containerHeight = height;
370
319
  }
320
+ }
321
+
322
+ /**
323
+ * CanvasRenderer - Handles canvas-based rendering for particle effects
324
+ * Provides high-performance drawing of emojis and images
325
+ */
326
+ class CanvasRenderer {
327
+ constructor(container, zIndex = 9999) {
328
+ this.spriteCache = new Map();
329
+ this.imageCache = new Map();
330
+ this.width = 0;
331
+ this.height = 0;
332
+ if (!isBrowser()) {
333
+ throw new Error('[falling-animation] CanvasRenderer requires a browser environment.');
334
+ }
335
+ // Create canvas element
336
+ this.canvas = document.createElement('canvas');
337
+ this.canvas.className = 'falling-animation-canvas';
338
+ // Get 2D context
339
+ const ctx = this.canvas.getContext('2d');
340
+ if (!ctx) {
341
+ throw new Error('[falling-animation] Could not get 2D context from canvas.');
342
+ }
343
+ this.ctx = ctx;
344
+ // Set canvas styles
345
+ const isBody = container === document.body;
346
+ Object.assign(this.canvas.style, {
347
+ position: isBody ? 'fixed' : 'absolute',
348
+ top: '0',
349
+ left: '0',
350
+ width: '100%',
351
+ height: '100%',
352
+ pointerEvents: 'none',
353
+ zIndex: String(zIndex)
354
+ });
355
+ // Ensure container has position for absolute canvas
356
+ if (!isBody) {
357
+ const containerPosition = getComputedStyle(container).position;
358
+ if (containerPosition === 'static') {
359
+ container.style.position = 'relative';
360
+ }
361
+ }
362
+ // Append to container
363
+ container.appendChild(this.canvas);
364
+ // Set initial size
365
+ this.resize();
366
+ }
367
+ /**
368
+ * Resize canvas to match container
369
+ */
370
+ resize() {
371
+ var _a;
372
+ const rect = (_a = this.canvas.parentElement) === null || _a === void 0 ? void 0 : _a.getBoundingClientRect();
373
+ if (rect) {
374
+ const dpr = window.devicePixelRatio || 1;
375
+ this.width = rect.width;
376
+ this.height = rect.height;
377
+ this.canvas.width = this.width * dpr;
378
+ this.canvas.height = this.height * dpr;
379
+ this.ctx.scale(dpr, dpr);
380
+ }
381
+ }
382
+ /**
383
+ * Get container dimensions
384
+ */
385
+ getSize() {
386
+ return { width: this.width, height: this.height };
387
+ }
388
+ /**
389
+ * Clear the entire canvas
390
+ */
391
+ clear() {
392
+ this.ctx.clearRect(0, 0, this.width, this.height);
393
+ }
394
+ /**
395
+ * Pre-cache an emoji as a sprite for fast drawing (HiDPI aware)
396
+ */
397
+ cacheEmoji(content, size) {
398
+ const dpr = window.devicePixelRatio || 1;
399
+ const key = `emoji:${content}:${size}:${dpr}`;
400
+ if (this.spriteCache.has(key)) {
401
+ return this.spriteCache.get(key);
402
+ }
403
+ // Create off-screen canvas for the emoji at HiDPI resolution
404
+ const spriteCanvas = document.createElement('canvas');
405
+ const padding = size * 0.3; // Padding for effects and emoji overflow
406
+ const logicalSize = size + padding * 2;
407
+ const physicalSize = logicalSize * dpr;
408
+ spriteCanvas.width = physicalSize;
409
+ spriteCanvas.height = physicalSize;
410
+ const spriteCtx = spriteCanvas.getContext('2d');
411
+ // Scale for HiDPI
412
+ spriteCtx.scale(dpr, dpr);
413
+ // High quality rendering
414
+ spriteCtx.imageSmoothingEnabled = true;
415
+ spriteCtx.imageSmoothingQuality = 'high';
416
+ // Draw emoji with proper font settings
417
+ const fontSize = size * 1.0; // Emoji font size
418
+ spriteCtx.font = `${fontSize}px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", "Android Emoji", "EmojiOne", sans-serif`;
419
+ spriteCtx.textAlign = 'center';
420
+ spriteCtx.textBaseline = 'middle';
421
+ spriteCtx.fillText(content, logicalSize / 2, logicalSize / 2);
422
+ const sprite = {
423
+ canvas: spriteCanvas,
424
+ width: logicalSize,
425
+ height: logicalSize
426
+ };
427
+ this.spriteCache.set(key, sprite);
428
+ return sprite;
429
+ }
430
+ /**
431
+ * Pre-load an image
432
+ */
433
+ preloadImage(src) {
434
+ if (this.imageCache.has(src)) {
435
+ return Promise.resolve(this.imageCache.get(src));
436
+ }
437
+ return new Promise((resolve, reject) => {
438
+ const img = new Image();
439
+ img.crossOrigin = 'anonymous';
440
+ img.onload = () => {
441
+ this.imageCache.set(src, img);
442
+ resolve(img);
443
+ };
444
+ img.onerror = reject;
445
+ img.src = src;
446
+ });
447
+ }
448
+ /**
449
+ * Draw a single particle (emoji or image)
450
+ */
451
+ drawParticle(particle) {
452
+ const { x, y, rotation, size, opacity, content, type } = particle;
453
+ this.ctx.save();
454
+ this.ctx.globalAlpha = opacity;
455
+ this.ctx.translate(x, y);
456
+ this.ctx.rotate((rotation * Math.PI) / 180);
457
+ if (type === 'emoji') {
458
+ const sprite = this.cacheEmoji(content, Math.round(size));
459
+ this.ctx.drawImage(sprite.canvas, -sprite.width / 2, -sprite.height / 2, sprite.width, sprite.height);
460
+ }
461
+ else if (type === 'image') {
462
+ const img = this.imageCache.get(content);
463
+ if (img) {
464
+ this.ctx.drawImage(img, -size / 2, -size / 2, size, size);
465
+ }
466
+ }
467
+ this.ctx.restore();
468
+ }
371
469
  /**
372
- * Remove particle from DOM
470
+ * Draw multiple particles efficiently
471
+ */
472
+ drawParticles(particles) {
473
+ for (const particle of particles) {
474
+ this.drawParticle(particle);
475
+ }
476
+ }
477
+ /**
478
+ * Draw a firework particle (glowing circle)
479
+ */
480
+ drawFireworkParticle(particle) {
481
+ const { x, y, size, opacity, color } = particle;
482
+ this.ctx.save();
483
+ this.ctx.globalAlpha = opacity;
484
+ // Draw glow
485
+ this.ctx.shadowColor = color;
486
+ this.ctx.shadowBlur = size * 2;
487
+ // Draw particle
488
+ this.ctx.fillStyle = color;
489
+ this.ctx.beginPath();
490
+ this.ctx.arc(x, y, size, 0, Math.PI * 2);
491
+ this.ctx.fill();
492
+ this.ctx.restore();
493
+ }
494
+ /**
495
+ * Draw multiple firework particles
496
+ */
497
+ drawFireworkParticles(particles) {
498
+ for (const particle of particles) {
499
+ this.drawFireworkParticle(particle);
500
+ }
501
+ }
502
+ /**
503
+ * Destroy the renderer and remove canvas from DOM
373
504
  */
374
505
  destroy() {
375
- this.element.remove();
506
+ this.canvas.remove();
507
+ this.spriteCache.clear();
508
+ this.imageCache.clear();
376
509
  }
377
510
  }
378
511
 
379
512
  /**
380
- * FallingAnimation - Main class for creating falling object animations
513
+ * FallingAnimation - Main class for creating falling object animations (Canvas-based)
381
514
  */
382
515
  /** Default configuration values */
383
516
  const DEFAULTS$1 = {
@@ -395,7 +528,7 @@ const DEFAULTS$1 = {
395
528
  class FallingAnimation {
396
529
  constructor(options) {
397
530
  this.particles = [];
398
- this.wrapper = null;
531
+ this.renderer = null;
399
532
  this.isRunning = false;
400
533
  this.isPaused = false;
401
534
  this.animationId = null;
@@ -407,7 +540,7 @@ class FallingAnimation {
407
540
  * Main animation loop
408
541
  */
409
542
  this.animate = (currentTime) => {
410
- if (!this.isRunning)
543
+ if (!this.isRunning || !this.renderer)
411
544
  return;
412
545
  // Calculate delta time
413
546
  if (this.lastFrameTime === 0) {
@@ -433,6 +566,18 @@ class FallingAnimation {
433
566
  }
434
567
  // Remove out-of-bounds particles
435
568
  particlesToRemove.forEach(p => this.removeParticle(p));
569
+ // Clear and render all particles
570
+ this.renderer.clear();
571
+ const renderData = this.particles.map(p => ({
572
+ x: p.x,
573
+ y: p.y,
574
+ rotation: p.rotation,
575
+ size: p.size,
576
+ opacity: p.opacity,
577
+ content: p.content,
578
+ type: p.objectType === 'image' ? 'image' : 'emoji'
579
+ }));
580
+ this.renderer.drawParticles(renderData);
436
581
  // Continue animation loop
437
582
  this.animationId = requestAnimationFrame(this.animate);
438
583
  };
@@ -442,8 +587,10 @@ class FallingAnimation {
442
587
  }
443
588
  // Resolve options with defaults
444
589
  this.options = this.resolveOptions(options);
445
- // Create wrapper element
446
- this.createWrapper();
590
+ // Create canvas renderer
591
+ this.renderer = new CanvasRenderer(this.options.container, this.options.zIndex);
592
+ // Preload images if any
593
+ this.preloadImages();
447
594
  // Setup resize handler if responsive
448
595
  if (this.options.responsive) {
449
596
  this.setupResizeHandler();
@@ -453,6 +600,26 @@ class FallingAnimation {
453
600
  this.start();
454
601
  }
455
602
  }
603
+ /**
604
+ * Preload any image objects
605
+ */
606
+ async preloadImages() {
607
+ if (!this.renderer)
608
+ return;
609
+ for (const obj of this.options.objects) {
610
+ if (obj.type === 'image') {
611
+ const src = obj.src || obj.content;
612
+ if (src) {
613
+ try {
614
+ await this.renderer.preloadImage(src);
615
+ }
616
+ catch (e) {
617
+ console.warn(`[falling-animation] Failed to preload image: ${src}`);
618
+ }
619
+ }
620
+ }
621
+ }
622
+ }
456
623
  /**
457
624
  * Merge user options with defaults
458
625
  */
@@ -485,42 +652,18 @@ class FallingAnimation {
485
652
  responsive: (_j = options.responsive) !== null && _j !== void 0 ? _j : DEFAULTS$1.responsive
486
653
  };
487
654
  }
488
- /**
489
- * Create wrapper element for particles
490
- */
491
- createWrapper() {
492
- this.wrapper = document.createElement('div');
493
- this.wrapper.className = 'falling-animation-wrapper';
494
- // Apply styles
495
- Object.assign(this.wrapper.style, {
496
- position: this.options.container === document.body ? 'fixed' : 'absolute',
497
- top: '0',
498
- left: '0',
499
- width: '100%',
500
- height: '100%',
501
- overflow: 'hidden',
502
- pointerEvents: 'none',
503
- zIndex: String(this.options.zIndex)
504
- });
505
- // Ensure container has position for absolute wrapper
506
- if (this.options.container !== document.body) {
507
- const containerPosition = getComputedStyle(this.options.container).position;
508
- if (containerPosition === 'static') {
509
- this.options.container.style.position = 'relative';
510
- }
511
- }
512
- this.options.container.appendChild(this.wrapper);
513
- }
514
655
  /**
515
656
  * Setup resize handler for responsive behavior
516
657
  */
517
658
  setupResizeHandler() {
518
659
  this.resizeHandler = throttle(() => {
519
- const width = this.options.container.clientWidth;
520
- const height = this.options.container.clientHeight;
521
- this.particles.forEach(particle => {
522
- particle.updateContainerSize(width, height);
523
- });
660
+ if (this.renderer) {
661
+ this.renderer.resize();
662
+ const { width, height } = this.renderer.getSize();
663
+ this.particles.forEach(particle => {
664
+ particle.updateContainerSize(width, height);
665
+ });
666
+ }
524
667
  }, 200);
525
668
  window.addEventListener('resize', this.resizeHandler);
526
669
  }
@@ -531,11 +674,11 @@ class FallingAnimation {
531
674
  if (this.particles.length >= this.options.maxParticles) {
532
675
  return;
533
676
  }
534
- const particle = new Particle(this.options);
677
+ if (!this.renderer)
678
+ return;
679
+ const { width, height } = this.renderer.getSize();
680
+ const particle = new Particle(this.options, width, height);
535
681
  this.particles.push(particle);
536
- if (this.wrapper) {
537
- this.wrapper.appendChild(particle.element);
538
- }
539
682
  }
540
683
  /**
541
684
  * Remove a particle
@@ -544,7 +687,6 @@ class FallingAnimation {
544
687
  const index = this.particles.indexOf(particle);
545
688
  if (index > -1) {
546
689
  this.particles.splice(index, 1);
547
- particle.destroy();
548
690
  }
549
691
  }
550
692
  /**
@@ -570,8 +712,11 @@ class FallingAnimation {
570
712
  this.animationId = null;
571
713
  }
572
714
  // Clear all particles
573
- this.particles.forEach(p => p.destroy());
574
715
  this.particles = [];
716
+ // Clear canvas
717
+ if (this.renderer) {
718
+ this.renderer.clear();
719
+ }
575
720
  }
576
721
  /**
577
722
  * Pause the animation (keeps particles in place)
@@ -627,6 +772,7 @@ class FallingAnimation {
627
772
  }
628
773
  if (newOptions.objects) {
629
774
  this.options.objects = newOptions.objects;
775
+ this.preloadImages(); // Preload any new images
630
776
  }
631
777
  }
632
778
  /**
@@ -652,10 +798,10 @@ class FallingAnimation {
652
798
  */
653
799
  destroy() {
654
800
  this.stop();
655
- // Remove wrapper
656
- if (this.wrapper) {
657
- this.wrapper.remove();
658
- this.wrapper = null;
801
+ // Destroy renderer
802
+ if (this.renderer) {
803
+ this.renderer.destroy();
804
+ this.renderer = null;
659
805
  }
660
806
  // Remove resize handler
661
807
  if (this.resizeHandler) {
@@ -666,14 +812,14 @@ class FallingAnimation {
666
812
  }
667
813
 
668
814
  /**
669
- * Fireworks Animation - Creates realistic firework effects
815
+ * Fireworks Animation - Creates realistic firework effects (Canvas-based)
670
816
  *
671
817
  * Features:
672
818
  * - Rockets shooting up from bottom
673
819
  * - Explosions spreading particles in all directions (360°)
674
820
  * - Gravity effect on particles
675
821
  * - Fade out over time
676
- * - Trail effects
822
+ * - Glow effects
677
823
  */
678
824
  /** Default colors for fireworks */
679
825
  const DEFAULT_COLORS = [
@@ -688,7 +834,6 @@ const DEFAULT_COLORS = [
688
834
  '#ff69b4', // Pink
689
835
  '#ffd700', // Gold
690
836
  ];
691
- /** Default configuration */
692
837
  const DEFAULTS = {
693
838
  colors: DEFAULT_COLORS,
694
839
  launchRate: 1,
@@ -699,14 +844,15 @@ const DEFAULTS = {
699
844
  particleLifetime: { min: 1000, max: 2000 },
700
845
  gravity: 0.1,
701
846
  trail: true,
847
+ explosionPattern: 'circular',
702
848
  autoStart: true,
703
849
  zIndex: 9999
704
850
  };
705
851
  class Fireworks {
706
852
  constructor(options = {}) {
707
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
853
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
708
854
  this.particles = [];
709
- this.wrapper = null;
855
+ this.renderer = null;
710
856
  this.isRunning = false;
711
857
  this.animationId = null;
712
858
  this.lastLaunchTime = 0;
@@ -714,7 +860,7 @@ class Fireworks {
714
860
  this.resizeHandler = null;
715
861
  this.animate = (currentTime) => {
716
862
  var _a;
717
- if (!this.isRunning)
863
+ if (!this.isRunning || !this.renderer)
718
864
  return;
719
865
  if (this.lastFrameTime === 0) {
720
866
  this.lastFrameTime = currentTime;
@@ -733,47 +879,62 @@ class Fireworks {
733
879
  for (const particle of this.particles) {
734
880
  particle.age += deltaTime;
735
881
  if (particle.phase === 'rocket') {
736
- // Update rocket
737
882
  particle.x += particle.vx * deltaTime * 0.1;
738
883
  particle.y += particle.vy * deltaTime * 0.1;
739
- particle.vy += 0.01 * deltaTime; // Slow down slightly as it rises
740
- // Check if ready to explode (reached target height or max age)
884
+ particle.vy += 0.01 * deltaTime;
741
885
  const targetY = (_a = particle.targetY) !== null && _a !== void 0 ? _a : 0;
742
886
  if (particle.y <= targetY || particle.age >= particle.maxAge || particle.vy >= 0) {
743
887
  particlesToExplode.push(particle);
744
888
  }
745
- // Trail effect - dim the rocket slightly
746
889
  particle.opacity = Math.max(0.5, 1 - (particle.age / particle.maxAge) * 0.3);
747
890
  }
748
891
  else {
749
- // Update explosion particle
750
892
  particle.x += particle.vx * deltaTime * 0.1;
751
893
  particle.y += particle.vy * deltaTime * 0.1;
752
- particle.vy += particle.gravity * deltaTime * 0.01; // Apply gravity
753
- // Fade out
894
+ particle.vy += particle.gravity * deltaTime * 0.01;
754
895
  const lifeProgress = particle.age / particle.maxAge;
755
896
  particle.opacity = Math.max(0, 1 - lifeProgress);
756
- // Slow down
757
- particle.vx *= 0.99;
758
- particle.vy *= 0.99;
759
- // Check if dead
897
+ // Higher drag for small particles (embers effect)
898
+ const drag = particle.size < 2 ? 0.96 : 0.99;
899
+ particle.vx *= drag;
900
+ particle.vy *= drag;
901
+ // Double pattern secondary explosion: trigger at 50% lifetime for stage 1 particles
902
+ if (particle.stage === 1 && lifeProgress >= 0.5 && particle.targetY !== -1) {
903
+ // Mark as already triggered secondary explosion
904
+ particle.targetY = -1;
905
+ particlesToExplode.push(particle);
906
+ }
760
907
  if (particle.age >= particle.maxAge) {
761
908
  particlesToRemove.push(particle);
762
909
  }
763
910
  }
764
- // Update DOM
765
- particle.element.style.transform = `translate3d(${particle.x}px, ${particle.y}px, 0)`;
766
- particle.element.style.opacity = String(particle.opacity);
767
911
  }
768
912
  // Handle explosions
769
913
  for (const rocket of particlesToExplode) {
770
- this.explode(rocket);
914
+ if (rocket.stage === 1) {
915
+ // Stage 1 particle: trigger secondary explosion with smaller particles
916
+ this.explodeSecondary(rocket);
917
+ }
918
+ else {
919
+ // Rocket: normal explosion
920
+ this.explode(rocket);
921
+ }
771
922
  particlesToRemove.push(rocket);
772
923
  }
773
924
  // Remove dead particles
774
925
  for (const particle of particlesToRemove) {
775
926
  this.removeParticle(particle);
776
927
  }
928
+ // Render all particles
929
+ this.renderer.clear();
930
+ const renderData = this.particles.map(p => ({
931
+ x: p.x,
932
+ y: p.y,
933
+ size: p.size,
934
+ opacity: p.opacity,
935
+ color: p.color
936
+ }));
937
+ this.renderer.drawFireworkParticles(renderData);
777
938
  this.animationId = requestAnimationFrame(this.animate);
778
939
  };
779
940
  // Check for browser environment
@@ -803,130 +964,313 @@ class Fireworks {
803
964
  particleLifetime: (_g = options.particleLifetime) !== null && _g !== void 0 ? _g : DEFAULTS.particleLifetime,
804
965
  gravity: (_h = options.gravity) !== null && _h !== void 0 ? _h : DEFAULTS.gravity,
805
966
  trail: (_j = options.trail) !== null && _j !== void 0 ? _j : DEFAULTS.trail,
806
- autoStart: (_k = options.autoStart) !== null && _k !== void 0 ? _k : DEFAULTS.autoStart,
807
- zIndex: (_l = options.zIndex) !== null && _l !== void 0 ? _l : DEFAULTS.zIndex
967
+ explosionPattern: (_k = options.explosionPattern) !== null && _k !== void 0 ? _k : DEFAULTS.explosionPattern,
968
+ autoStart: (_l = options.autoStart) !== null && _l !== void 0 ? _l : DEFAULTS.autoStart,
969
+ zIndex: (_m = options.zIndex) !== null && _m !== void 0 ? _m : DEFAULTS.zIndex
808
970
  };
809
- this.createWrapper();
971
+ // Create canvas renderer
972
+ this.renderer = new CanvasRenderer(container, this.options.zIndex);
810
973
  this.setupResizeHandler();
811
974
  if (this.options.autoStart) {
812
975
  this.start();
813
976
  }
814
977
  }
815
- createWrapper() {
816
- this.wrapper = document.createElement('div');
817
- this.wrapper.className = 'fireworks-wrapper';
818
- Object.assign(this.wrapper.style, {
819
- position: this.options.container === document.body ? 'fixed' : 'absolute',
820
- top: '0',
821
- left: '0',
822
- width: '100%',
823
- height: '100%',
824
- overflow: 'hidden',
825
- pointerEvents: 'none',
826
- zIndex: String(this.options.zIndex)
827
- });
828
- if (this.options.container !== document.body) {
829
- const containerPosition = getComputedStyle(this.options.container).position;
830
- if (containerPosition === 'static') {
831
- this.options.container.style.position = 'relative';
832
- }
833
- }
834
- this.options.container.appendChild(this.wrapper);
835
- }
836
978
  setupResizeHandler() {
837
979
  this.resizeHandler = throttle(() => {
838
- // Handle resize if needed
980
+ if (this.renderer) {
981
+ this.renderer.resize();
982
+ }
839
983
  }, 200);
840
984
  window.addEventListener('resize', this.resizeHandler);
841
985
  }
842
986
  getContainerSize() {
843
- return {
844
- width: this.options.container.clientWidth,
845
- height: this.options.container.clientHeight
846
- };
847
- }
848
- createParticleElement(particle) {
849
- const element = document.createElement('div');
850
- element.className = 'firework-particle';
851
- Object.assign(element.style, {
852
- position: 'absolute',
853
- left: '0',
854
- top: '0',
855
- width: `${particle.size}px`,
856
- height: `${particle.size}px`,
857
- backgroundColor: particle.color,
858
- borderRadius: '50%',
859
- boxShadow: `0 0 ${particle.size * 2}px ${particle.color}`,
860
- opacity: String(particle.opacity),
861
- pointerEvents: 'none',
862
- willChange: 'transform, opacity',
863
- transform: `translate3d(${particle.x}px, ${particle.y}px, 0)`
864
- });
865
- return element;
987
+ if (this.renderer) {
988
+ return this.renderer.getSize();
989
+ }
990
+ return { width: 0, height: 0 };
866
991
  }
867
992
  launchRocket() {
868
993
  const { width, height } = this.getContainerSize();
994
+ if (width === 0 || height === 0)
995
+ return;
869
996
  const color = this.options.colors[Math.floor(Math.random() * this.options.colors.length)];
870
997
  // Random explosion height: 10% to 90% from top of screen
871
998
  const targetY = randomRange(height * 0.1, height * 0.9);
872
999
  const distanceToTravel = height - targetY;
873
1000
  // Calculate time based on distance and speed
874
1001
  const rocketSpeed = randomFromRange(this.options.rocketSpeed);
875
- const travelTime = distanceToTravel / (rocketSpeed * 0.1); // approximate time in ms
1002
+ const travelTime = distanceToTravel / (rocketSpeed * 0.1);
876
1003
  const particle = {
877
1004
  id: generateId(),
878
- element: null,
879
1005
  x: randomRange(width * 0.1, width * 0.9),
880
- y: height + 10, // Start slightly below viewport
1006
+ y: height + 10,
881
1007
  vx: randomRange(-0.5, 0.5),
882
1008
  vy: -rocketSpeed,
883
1009
  size: 4,
884
1010
  opacity: 1,
885
1011
  color,
886
1012
  age: 0,
887
- maxAge: Math.min(travelTime, 3000), // Cap at 3 seconds
1013
+ maxAge: Math.min(travelTime, 3000),
888
1014
  phase: 'rocket',
889
1015
  gravity: 0,
890
- targetY // Store target explosion height
1016
+ targetY,
1017
+ stage: 0
891
1018
  };
892
- particle.element = this.createParticleElement(particle);
893
1019
  this.particles.push(particle);
894
- if (this.wrapper) {
895
- this.wrapper.appendChild(particle.element);
896
- }
897
1020
  }
898
1021
  explode(rocket) {
899
1022
  const particleCount = this.options.particlesPerExplosion;
900
1023
  const color = rocket.color;
901
- // Create explosion particles spreading in all directions
902
- for (let i = 0; i < particleCount; i++) {
903
- // Spread evenly in 360 degrees
904
- const angle = (i / particleCount) * Math.PI * 2 + randomRange(-0.1, 0.1);
1024
+ // Get pattern (resolve 'random' or array to single pattern)
1025
+ let pattern;
1026
+ const patternOption = this.options.explosionPattern;
1027
+ if (Array.isArray(patternOption)) {
1028
+ // Pick random from array
1029
+ pattern = patternOption[Math.floor(Math.random() * patternOption.length)];
1030
+ }
1031
+ else if (patternOption === 'random') {
1032
+ const patterns = ['circular', 'ring', 'heart', 'star', 'willow', 'palm', 'chrysanthemum', 'embers', 'double'];
1033
+ pattern = patterns[Math.floor(Math.random() * patterns.length)];
1034
+ }
1035
+ else {
1036
+ pattern = patternOption;
1037
+ }
1038
+ // Generate particles based on pattern
1039
+ const particles = this.generatePatternParticles(pattern, rocket, particleCount, color);
1040
+ for (const particle of particles) {
1041
+ this.particles.push(particle);
1042
+ }
1043
+ }
1044
+ generatePatternParticles(pattern, rocket, count, color) {
1045
+ switch (pattern) {
1046
+ case 'ring':
1047
+ return this.createRingExplosion(rocket, count, color);
1048
+ case 'heart':
1049
+ return this.createHeartExplosion(rocket, count, color);
1050
+ case 'star':
1051
+ return this.createStarExplosion(rocket, count, color);
1052
+ case 'willow':
1053
+ return this.createWillowExplosion(rocket, count, color);
1054
+ case 'palm':
1055
+ return this.createPalmExplosion(rocket, count, color);
1056
+ case 'chrysanthemum':
1057
+ return this.createChrysanthemumExplosion(rocket, count, color);
1058
+ case 'embers':
1059
+ return this.createEmbersExplosion(rocket, count, color);
1060
+ case 'double':
1061
+ return this.createDoubleExplosion(rocket, count, color);
1062
+ case 'circular':
1063
+ default:
1064
+ return this.createCircularExplosion(rocket, count, color);
1065
+ }
1066
+ }
1067
+ /** Standard circular explosion - even spread */
1068
+ createCircularExplosion(rocket, count, color) {
1069
+ const particles = [];
1070
+ for (let i = 0; i < count; i++) {
1071
+ const angle = (i / count) * Math.PI * 2 + randomRange(-0.1, 0.1);
905
1072
  const speed = randomFromRange(this.options.explosionSpeed);
1073
+ particles.push(this.createExplosionParticle(rocket, color, angle, speed));
1074
+ }
1075
+ return particles;
1076
+ }
1077
+ /** Ring/donut shape - particles at similar radius */
1078
+ createRingExplosion(rocket, count, color) {
1079
+ const particles = [];
1080
+ const baseSpeed = (this.options.explosionSpeed.min + this.options.explosionSpeed.max) / 2;
1081
+ for (let i = 0; i < count; i++) {
1082
+ const angle = (i / count) * Math.PI * 2;
1083
+ const speed = baseSpeed + randomRange(-0.5, 0.5);
1084
+ particles.push(this.createExplosionParticle(rocket, color, angle, speed, 0.05)); // Low gravity
1085
+ }
1086
+ return particles;
1087
+ }
1088
+ /** Heart shape explosion */
1089
+ createHeartExplosion(rocket, count, color) {
1090
+ const particles = [];
1091
+ for (let i = 0; i < count; i++) {
1092
+ const t = (i / count) * Math.PI * 2;
1093
+ // Heart parametric equation
1094
+ const heartX = 16 * Math.pow(Math.sin(t), 3);
1095
+ const heartY = -(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t));
1096
+ const angle = Math.atan2(heartY, heartX);
1097
+ const speed = Math.sqrt(heartX * heartX + heartY * heartY) / 16 * randomFromRange(this.options.explosionSpeed);
1098
+ particles.push(this.createExplosionParticle(rocket, color, angle, speed * 0.8, 0.08));
1099
+ }
1100
+ return particles;
1101
+ }
1102
+ /** Star burst - concentrated beams */
1103
+ createStarExplosion(rocket, count, color) {
1104
+ const particles = [];
1105
+ const points = 5; // 5-pointed star
1106
+ for (let i = 0; i < count; i++) {
1107
+ const starAngle = (Math.floor(i / (count / points)) / points) * Math.PI * 2;
1108
+ const spreadAngle = starAngle + randomRange(-0.15, 0.15);
1109
+ const speed = randomFromRange(this.options.explosionSpeed) * (0.8 + Math.random() * 0.4);
1110
+ particles.push(this.createExplosionParticle(rocket, color, spreadAngle, speed));
1111
+ }
1112
+ return particles;
1113
+ }
1114
+ /** Willow - heavy gravity, long trails */
1115
+ createWillowExplosion(rocket, count, color) {
1116
+ const particles = [];
1117
+ for (let i = 0; i < count; i++) {
1118
+ const angle = (i / count) * Math.PI * 2 + randomRange(-0.1, 0.1);
1119
+ const speed = randomFromRange(this.options.explosionSpeed) * 0.6;
1120
+ const p = this.createExplosionParticle(rocket, '#ffd700', angle, speed, 0.25); // Gold, high gravity
1121
+ p.maxAge *= 1.5; // Longer life
1122
+ particles.push(p);
1123
+ }
1124
+ return particles;
1125
+ }
1126
+ /** Palm tree - upward bias */
1127
+ createPalmExplosion(rocket, count, color) {
1128
+ const particles = [];
1129
+ for (let i = 0; i < count; i++) {
1130
+ // Bias upward (angles from -60 to 240 degrees, avoiding straight down)
1131
+ const angle = randomRange(-Math.PI * 0.8, Math.PI * 0.8) - Math.PI / 2;
1132
+ const speed = randomFromRange(this.options.explosionSpeed) * (0.7 + Math.random() * 0.6);
1133
+ const p = this.createExplosionParticle(rocket, color, angle, speed, 0.18);
1134
+ p.maxAge *= 1.2;
1135
+ particles.push(p);
1136
+ }
1137
+ return particles;
1138
+ }
1139
+ /** Chrysanthemum - dense spherical burst */
1140
+ createChrysanthemumExplosion(rocket, count, color) {
1141
+ const particles = [];
1142
+ const layers = 3;
1143
+ for (let layer = 0; layer < layers; layer++) {
1144
+ const layerCount = Math.floor(count / layers);
1145
+ const baseSpeed = this.options.explosionSpeed.min +
1146
+ (this.options.explosionSpeed.max - this.options.explosionSpeed.min) * (layer / layers);
1147
+ for (let i = 0; i < layerCount; i++) {
1148
+ const angle = (i / layerCount) * Math.PI * 2 + (layer * 0.3);
1149
+ const speed = baseSpeed + randomRange(-0.5, 0.5);
1150
+ particles.push(this.createExplosionParticle(rocket, color, angle, speed, 0.08));
1151
+ }
1152
+ }
1153
+ return particles;
1154
+ }
1155
+ /** Embers - Slow-falling micro particles (Tàn Lửa Trôi Nhẹ)
1156
+ * Very small particles (0.5-1.5px) with low gravity and high drag
1157
+ */
1158
+ createEmbersExplosion(rocket, count, color) {
1159
+ const particles = [];
1160
+ // More particles for embers effect (1.5x normal count)
1161
+ const emberCount = Math.floor(count * 1.5);
1162
+ for (let i = 0; i < emberCount; i++) {
1163
+ const angle = (i / emberCount) * Math.PI * 2 + randomRange(-0.3, 0.3);
1164
+ // Very slow initial speed
1165
+ const speed = randomFromRange(this.options.explosionSpeed) * 0.3;
1166
+ const particle = {
1167
+ id: generateId(),
1168
+ x: rocket.x + randomRange(-5, 5), // Slight position randomness
1169
+ y: rocket.y + randomRange(-5, 5),
1170
+ vx: Math.cos(angle) * speed,
1171
+ vy: Math.sin(angle) * speed,
1172
+ // Very small particles (0.5 - 1.5 px)
1173
+ size: randomRange(0.5, 1.5),
1174
+ opacity: 1,
1175
+ // Warm ember colors
1176
+ color: this.getEmberColor(color),
1177
+ age: 0,
1178
+ // Long lifetime for slow fade
1179
+ maxAge: randomFromRange(this.options.particleLifetime) * 2,
1180
+ phase: 'explosion',
1181
+ // Very low gravity - particles float
1182
+ gravity: 0.02,
1183
+ stage: rocket.stage + 1
1184
+ };
1185
+ particles.push(particle);
1186
+ }
1187
+ return particles;
1188
+ }
1189
+ /** Double explosion - first explosion creates particles that explode again
1190
+ * Secondary particles are 2-3x smaller than primary
1191
+ */
1192
+ createDoubleExplosion(rocket, count, color) {
1193
+ const particles = [];
1194
+ // First explosion: create fewer, larger particles that will explode again
1195
+ const primaryCount = Math.floor(count * 0.6);
1196
+ for (let i = 0; i < primaryCount; i++) {
1197
+ const angle = (i / primaryCount) * Math.PI * 2 + randomRange(-0.1, 0.1);
1198
+ const speed = randomFromRange(this.options.explosionSpeed) * 0.8;
906
1199
  const particle = {
907
1200
  id: generateId(),
908
- element: null,
909
1201
  x: rocket.x,
910
1202
  y: rocket.y,
911
1203
  vx: Math.cos(angle) * speed,
912
1204
  vy: Math.sin(angle) * speed,
1205
+ // Normal size for primary particles
913
1206
  size: randomFromRange(this.options.particleSize),
914
1207
  opacity: 1,
915
1208
  color: this.getVariantColor(color),
916
1209
  age: 0,
917
1210
  maxAge: randomFromRange(this.options.particleLifetime),
918
1211
  phase: 'explosion',
919
- gravity: this.options.gravity
1212
+ gravity: this.options.gravity,
1213
+ stage: 1 // Mark as stage 1 - will trigger secondary explosion
1214
+ };
1215
+ particles.push(particle);
1216
+ }
1217
+ return particles;
1218
+ }
1219
+ /** Create secondary explosion particles (called when stage 1 particles reach 50% lifetime)
1220
+ * These particles are 2-3x smaller than the originals
1221
+ */
1222
+ explodeSecondary(parent) {
1223
+ const secondaryCount = Math.floor(this.options.particlesPerExplosion * 0.3); // 30% of normal
1224
+ for (let i = 0; i < secondaryCount; i++) {
1225
+ const angle = (i / secondaryCount) * Math.PI * 2 + randomRange(-0.2, 0.2);
1226
+ const speed = randomFromRange(this.options.explosionSpeed) * 0.5;
1227
+ const particle = {
1228
+ id: generateId(),
1229
+ x: parent.x,
1230
+ y: parent.y,
1231
+ vx: Math.cos(angle) * speed,
1232
+ vy: Math.sin(angle) * speed,
1233
+ // Secondary particles are 2-3x smaller!
1234
+ size: Math.max(1, randomFromRange(this.options.particleSize) / randomRange(2, 3)),
1235
+ opacity: 0.9,
1236
+ color: this.getVariantColor(parent.color),
1237
+ age: 0,
1238
+ maxAge: randomFromRange(this.options.particleLifetime) * 0.7, // Shorter lifetime
1239
+ phase: 'explosion',
1240
+ gravity: this.options.gravity * 0.5, // Less gravity
1241
+ stage: 2 // Stage 2 - no more explosions
920
1242
  };
921
- particle.element = this.createParticleElement(particle);
922
1243
  this.particles.push(particle);
923
- if (this.wrapper) {
924
- this.wrapper.appendChild(particle.element);
925
- }
926
1244
  }
927
1245
  }
1246
+ /** Get warm ember color variant */
1247
+ getEmberColor(baseColor) {
1248
+ const emberColors = ['#ffaa00', '#ff6600', '#ff4400', '#ffcc00', '#ff8800', '#ffffff'];
1249
+ if (Math.random() < 0.7) {
1250
+ // 70% chance of ember colors
1251
+ return emberColors[Math.floor(Math.random() * emberColors.length)];
1252
+ }
1253
+ return baseColor;
1254
+ }
1255
+ /** Helper to create a single explosion particle */
1256
+ createExplosionParticle(rocket, color, angle, speed, gravity) {
1257
+ return {
1258
+ id: generateId(),
1259
+ x: rocket.x,
1260
+ y: rocket.y,
1261
+ vx: Math.cos(angle) * speed,
1262
+ vy: Math.sin(angle) * speed,
1263
+ size: randomFromRange(this.options.particleSize),
1264
+ opacity: 1,
1265
+ color: this.getVariantColor(color),
1266
+ age: 0,
1267
+ maxAge: randomFromRange(this.options.particleLifetime),
1268
+ phase: 'explosion',
1269
+ gravity: gravity !== null && gravity !== void 0 ? gravity : this.options.gravity,
1270
+ stage: rocket.stage + 1
1271
+ };
1272
+ }
928
1273
  getVariantColor(baseColor) {
929
- // Sometimes return white or a lighter variant for sparkle effect
930
1274
  if (Math.random() < 0.2) {
931
1275
  return '#ffffff';
932
1276
  }
@@ -939,7 +1283,6 @@ class Fireworks {
939
1283
  const index = this.particles.indexOf(particle);
940
1284
  if (index > -1) {
941
1285
  this.particles.splice(index, 1);
942
- particle.element.remove();
943
1286
  }
944
1287
  }
945
1288
  /** Start the fireworks */
@@ -961,10 +1304,10 @@ class Fireworks {
961
1304
  }
962
1305
  /** Clear all particles but keep running */
963
1306
  clear() {
964
- for (const particle of this.particles) {
965
- particle.element.remove();
966
- }
967
1307
  this.particles = [];
1308
+ if (this.renderer) {
1309
+ this.renderer.clear();
1310
+ }
968
1311
  }
969
1312
  /** Launch a single firework manually */
970
1313
  launch() {
@@ -994,6 +1337,8 @@ class Fireworks {
994
1337
  this.options.particleLifetime = newOptions.particleLifetime;
995
1338
  if (newOptions.gravity !== undefined)
996
1339
  this.options.gravity = newOptions.gravity;
1340
+ if (newOptions.explosionPattern)
1341
+ this.options.explosionPattern = newOptions.explosionPattern;
997
1342
  }
998
1343
  /** Get particle count */
999
1344
  getParticleCount() {
@@ -1007,9 +1352,9 @@ class Fireworks {
1007
1352
  destroy() {
1008
1353
  this.stop();
1009
1354
  this.clear();
1010
- if (this.wrapper) {
1011
- this.wrapper.remove();
1012
- this.wrapper = null;
1355
+ if (this.renderer) {
1356
+ this.renderer.destroy();
1357
+ this.renderer = null;
1013
1358
  }
1014
1359
  if (this.resizeHandler) {
1015
1360
  window.removeEventListener('resize', this.resizeHandler);