falling-animation 0.1.1 → 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.
@@ -45,10 +45,21 @@ let idCounter = 0;
45
45
  function generateId() {
46
46
  return ++idCounter;
47
47
  }
48
+ /**
49
+ * Check if code is running in browser environment
50
+ */
51
+ function isBrowser() {
52
+ return typeof window !== 'undefined' && typeof document !== 'undefined';
53
+ }
48
54
  /**
49
55
  * Resolve container from element or selector
56
+ * Throws an error if called in non-browser environment
50
57
  */
51
58
  function resolveContainer(container) {
59
+ if (!isBrowser()) {
60
+ throw new Error('[falling-animation] This library requires a browser environment. ' +
61
+ 'If using SSR (Next.js, Nuxt, etc.), make sure to only initialize on the client side.');
62
+ }
52
63
  if (!container) {
53
64
  return document.body;
54
65
  }
@@ -232,7 +243,7 @@ function getAnimation(name) {
232
243
  }
233
244
 
234
245
  /**
235
- * Particle class - manages individual falling objects
246
+ * Particle class - manages individual falling objects (Canvas-based, no DOM)
236
247
  */
237
248
  /**
238
249
  * Speed multiplier to convert user-friendly speed units to px/ms
@@ -242,22 +253,23 @@ function getAnimation(name) {
242
253
  */
243
254
  const SPEED_MULTIPLIER = 0.04; // 40 px/s per unit, divided by 1000 for ms
244
255
  class Particle {
245
- constructor(options) {
246
- this.options = options;
256
+ constructor(options, containerWidth, containerHeight) {
247
257
  this.id = generateId();
248
258
  this.age = 0;
249
259
  this.phase = Math.random() * Math.PI * 2;
250
260
  this.data = {};
251
- // Get container dimensions
252
- this.containerWidth = options.container.clientWidth;
253
- this.containerHeight = options.container.clientHeight;
261
+ // Store container dimensions
262
+ this.containerWidth = containerWidth;
263
+ this.containerHeight = containerHeight;
254
264
  // Pick random object
255
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;
256
269
  // Initialize position at top with random x
257
270
  this.x = randomRange(-50, this.containerWidth + 50);
258
271
  this.y = -50;
259
272
  // Initialize velocity (apply speed multiplier for intuitive units)
260
- // speed=1 → ~40px/s (1cm/s), speed=0.1 → ~4px/s (1mm/s)
261
273
  this.vy = randomFromRange(options.speed) * SPEED_MULTIPLIER;
262
274
  this.vx = options.wind * SPEED_MULTIPLIER * randomRange(0.5, 1.5);
263
275
  // Initialize size and opacity
@@ -270,8 +282,6 @@ class Particle {
270
282
  this.animation = randomPick(options.animation);
271
283
  // Initialize animation-specific data
272
284
  this.initAnimationData();
273
- // Create DOM element
274
- this.element = this.createElement(object);
275
285
  }
276
286
  /**
277
287
  * Initialize animation-specific data
@@ -283,54 +293,6 @@ class Particle {
283
293
  this.data.wobbleY = randomRange(0.005, 0.015);
284
294
  }
285
295
  }
286
- /**
287
- * Create DOM element for the particle
288
- */
289
- createElement(object) {
290
- var _a, _b, _c, _d;
291
- const element = document.createElement('div');
292
- element.className = 'falling-particle';
293
- element.setAttribute('data-particle-id', String(this.id));
294
- // Set content based on type
295
- switch (object.type) {
296
- case 'emoji':
297
- element.textContent = (_a = object.content) !== null && _a !== void 0 ? _a : '❄️';
298
- element.style.fontSize = `${this.size}px`;
299
- element.style.lineHeight = '1';
300
- break;
301
- case 'image':
302
- const img = document.createElement('img');
303
- img.src = (_c = (_b = object.src) !== null && _b !== void 0 ? _b : object.content) !== null && _c !== void 0 ? _c : '';
304
- img.alt = '';
305
- img.style.width = `${this.size}px`;
306
- img.style.height = `${this.size}px`;
307
- img.style.objectFit = 'contain';
308
- img.draggable = false;
309
- element.appendChild(img);
310
- break;
311
- case 'html':
312
- element.innerHTML = (_d = object.content) !== null && _d !== void 0 ? _d : '';
313
- break;
314
- }
315
- // Apply base styles
316
- Object.assign(element.style, {
317
- position: 'absolute',
318
- pointerEvents: 'none',
319
- userSelect: 'none',
320
- willChange: 'transform, opacity',
321
- opacity: String(this.opacity),
322
- left: '0',
323
- top: '0',
324
- transform: this.getTransform()
325
- });
326
- return element;
327
- }
328
- /**
329
- * Get CSS transform string
330
- */
331
- getTransform() {
332
- return `translate3d(${this.x}px, ${this.y}px, 0) rotate(${this.rotation}deg)`;
333
- }
334
296
  /**
335
297
  * Update particle state
336
298
  */
@@ -339,8 +301,6 @@ class Particle {
339
301
  // Apply animation
340
302
  const animationFn = getAnimation(this.animation);
341
303
  animationFn(this, deltaTime, elapsed);
342
- // Update DOM
343
- this.element.style.transform = this.getTransform();
344
304
  }
345
305
  /**
346
306
  * Check if particle is out of bounds
@@ -357,16 +317,200 @@ class Particle {
357
317
  this.containerWidth = width;
358
318
  this.containerHeight = height;
359
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
+ }
469
+ /**
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
+ }
360
502
  /**
361
- * Remove particle from DOM
503
+ * Destroy the renderer and remove canvas from DOM
362
504
  */
363
505
  destroy() {
364
- this.element.remove();
506
+ this.canvas.remove();
507
+ this.spriteCache.clear();
508
+ this.imageCache.clear();
365
509
  }
366
510
  }
367
511
 
368
512
  /**
369
- * FallingAnimation - Main class for creating falling object animations
513
+ * FallingAnimation - Main class for creating falling object animations (Canvas-based)
370
514
  */
371
515
  /** Default configuration values */
372
516
  const DEFAULTS$1 = {
@@ -384,7 +528,7 @@ const DEFAULTS$1 = {
384
528
  class FallingAnimation {
385
529
  constructor(options) {
386
530
  this.particles = [];
387
- this.wrapper = null;
531
+ this.renderer = null;
388
532
  this.isRunning = false;
389
533
  this.isPaused = false;
390
534
  this.animationId = null;
@@ -396,7 +540,7 @@ class FallingAnimation {
396
540
  * Main animation loop
397
541
  */
398
542
  this.animate = (currentTime) => {
399
- if (!this.isRunning)
543
+ if (!this.isRunning || !this.renderer)
400
544
  return;
401
545
  // Calculate delta time
402
546
  if (this.lastFrameTime === 0) {
@@ -422,6 +566,18 @@ class FallingAnimation {
422
566
  }
423
567
  // Remove out-of-bounds particles
424
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);
425
581
  // Continue animation loop
426
582
  this.animationId = requestAnimationFrame(this.animate);
427
583
  };
@@ -431,8 +587,10 @@ class FallingAnimation {
431
587
  }
432
588
  // Resolve options with defaults
433
589
  this.options = this.resolveOptions(options);
434
- // Create wrapper element
435
- 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();
436
594
  // Setup resize handler if responsive
437
595
  if (this.options.responsive) {
438
596
  this.setupResizeHandler();
@@ -442,6 +600,26 @@ class FallingAnimation {
442
600
  this.start();
443
601
  }
444
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
+ }
445
623
  /**
446
624
  * Merge user options with defaults
447
625
  */
@@ -474,42 +652,18 @@ class FallingAnimation {
474
652
  responsive: (_j = options.responsive) !== null && _j !== void 0 ? _j : DEFAULTS$1.responsive
475
653
  };
476
654
  }
477
- /**
478
- * Create wrapper element for particles
479
- */
480
- createWrapper() {
481
- this.wrapper = document.createElement('div');
482
- this.wrapper.className = 'falling-animation-wrapper';
483
- // Apply styles
484
- Object.assign(this.wrapper.style, {
485
- position: this.options.container === document.body ? 'fixed' : 'absolute',
486
- top: '0',
487
- left: '0',
488
- width: '100%',
489
- height: '100%',
490
- overflow: 'hidden',
491
- pointerEvents: 'none',
492
- zIndex: String(this.options.zIndex)
493
- });
494
- // Ensure container has position for absolute wrapper
495
- if (this.options.container !== document.body) {
496
- const containerPosition = getComputedStyle(this.options.container).position;
497
- if (containerPosition === 'static') {
498
- this.options.container.style.position = 'relative';
499
- }
500
- }
501
- this.options.container.appendChild(this.wrapper);
502
- }
503
655
  /**
504
656
  * Setup resize handler for responsive behavior
505
657
  */
506
658
  setupResizeHandler() {
507
659
  this.resizeHandler = throttle(() => {
508
- const width = this.options.container.clientWidth;
509
- const height = this.options.container.clientHeight;
510
- this.particles.forEach(particle => {
511
- particle.updateContainerSize(width, height);
512
- });
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
+ }
513
667
  }, 200);
514
668
  window.addEventListener('resize', this.resizeHandler);
515
669
  }
@@ -520,11 +674,11 @@ class FallingAnimation {
520
674
  if (this.particles.length >= this.options.maxParticles) {
521
675
  return;
522
676
  }
523
- 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);
524
681
  this.particles.push(particle);
525
- if (this.wrapper) {
526
- this.wrapper.appendChild(particle.element);
527
- }
528
682
  }
529
683
  /**
530
684
  * Remove a particle
@@ -533,7 +687,6 @@ class FallingAnimation {
533
687
  const index = this.particles.indexOf(particle);
534
688
  if (index > -1) {
535
689
  this.particles.splice(index, 1);
536
- particle.destroy();
537
690
  }
538
691
  }
539
692
  /**
@@ -559,8 +712,11 @@ class FallingAnimation {
559
712
  this.animationId = null;
560
713
  }
561
714
  // Clear all particles
562
- this.particles.forEach(p => p.destroy());
563
715
  this.particles = [];
716
+ // Clear canvas
717
+ if (this.renderer) {
718
+ this.renderer.clear();
719
+ }
564
720
  }
565
721
  /**
566
722
  * Pause the animation (keeps particles in place)
@@ -616,6 +772,7 @@ class FallingAnimation {
616
772
  }
617
773
  if (newOptions.objects) {
618
774
  this.options.objects = newOptions.objects;
775
+ this.preloadImages(); // Preload any new images
619
776
  }
620
777
  }
621
778
  /**
@@ -641,10 +798,10 @@ class FallingAnimation {
641
798
  */
642
799
  destroy() {
643
800
  this.stop();
644
- // Remove wrapper
645
- if (this.wrapper) {
646
- this.wrapper.remove();
647
- this.wrapper = null;
801
+ // Destroy renderer
802
+ if (this.renderer) {
803
+ this.renderer.destroy();
804
+ this.renderer = null;
648
805
  }
649
806
  // Remove resize handler
650
807
  if (this.resizeHandler) {
@@ -655,14 +812,14 @@ class FallingAnimation {
655
812
  }
656
813
 
657
814
  /**
658
- * Fireworks Animation - Creates realistic firework effects
815
+ * Fireworks Animation - Creates realistic firework effects (Canvas-based)
659
816
  *
660
817
  * Features:
661
818
  * - Rockets shooting up from bottom
662
819
  * - Explosions spreading particles in all directions (360°)
663
820
  * - Gravity effect on particles
664
821
  * - Fade out over time
665
- * - Trail effects
822
+ * - Glow effects
666
823
  */
667
824
  /** Default colors for fireworks */
668
825
  const DEFAULT_COLORS = [
@@ -677,7 +834,6 @@ const DEFAULT_COLORS = [
677
834
  '#ff69b4', // Pink
678
835
  '#ffd700', // Gold
679
836
  ];
680
- /** Default configuration */
681
837
  const DEFAULTS = {
682
838
  colors: DEFAULT_COLORS,
683
839
  launchRate: 1,
@@ -688,14 +844,15 @@ const DEFAULTS = {
688
844
  particleLifetime: { min: 1000, max: 2000 },
689
845
  gravity: 0.1,
690
846
  trail: true,
847
+ explosionPattern: 'circular',
691
848
  autoStart: true,
692
849
  zIndex: 9999
693
850
  };
694
851
  class Fireworks {
695
852
  constructor(options = {}) {
696
- 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;
697
854
  this.particles = [];
698
- this.wrapper = null;
855
+ this.renderer = null;
699
856
  this.isRunning = false;
700
857
  this.animationId = null;
701
858
  this.lastLaunchTime = 0;
@@ -703,7 +860,7 @@ class Fireworks {
703
860
  this.resizeHandler = null;
704
861
  this.animate = (currentTime) => {
705
862
  var _a;
706
- if (!this.isRunning)
863
+ if (!this.isRunning || !this.renderer)
707
864
  return;
708
865
  if (this.lastFrameTime === 0) {
709
866
  this.lastFrameTime = currentTime;
@@ -722,49 +879,69 @@ class Fireworks {
722
879
  for (const particle of this.particles) {
723
880
  particle.age += deltaTime;
724
881
  if (particle.phase === 'rocket') {
725
- // Update rocket
726
882
  particle.x += particle.vx * deltaTime * 0.1;
727
883
  particle.y += particle.vy * deltaTime * 0.1;
728
- particle.vy += 0.01 * deltaTime; // Slow down slightly as it rises
729
- // Check if ready to explode (reached target height or max age)
884
+ particle.vy += 0.01 * deltaTime;
730
885
  const targetY = (_a = particle.targetY) !== null && _a !== void 0 ? _a : 0;
731
886
  if (particle.y <= targetY || particle.age >= particle.maxAge || particle.vy >= 0) {
732
887
  particlesToExplode.push(particle);
733
888
  }
734
- // Trail effect - dim the rocket slightly
735
889
  particle.opacity = Math.max(0.5, 1 - (particle.age / particle.maxAge) * 0.3);
736
890
  }
737
891
  else {
738
- // Update explosion particle
739
892
  particle.x += particle.vx * deltaTime * 0.1;
740
893
  particle.y += particle.vy * deltaTime * 0.1;
741
- particle.vy += particle.gravity * deltaTime * 0.01; // Apply gravity
742
- // Fade out
894
+ particle.vy += particle.gravity * deltaTime * 0.01;
743
895
  const lifeProgress = particle.age / particle.maxAge;
744
896
  particle.opacity = Math.max(0, 1 - lifeProgress);
745
- // Slow down
746
- particle.vx *= 0.99;
747
- particle.vy *= 0.99;
748
- // 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
+ }
749
907
  if (particle.age >= particle.maxAge) {
750
908
  particlesToRemove.push(particle);
751
909
  }
752
910
  }
753
- // Update DOM
754
- particle.element.style.transform = `translate3d(${particle.x}px, ${particle.y}px, 0)`;
755
- particle.element.style.opacity = String(particle.opacity);
756
911
  }
757
912
  // Handle explosions
758
913
  for (const rocket of particlesToExplode) {
759
- 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
+ }
760
922
  particlesToRemove.push(rocket);
761
923
  }
762
924
  // Remove dead particles
763
925
  for (const particle of particlesToRemove) {
764
926
  this.removeParticle(particle);
765
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);
766
938
  this.animationId = requestAnimationFrame(this.animate);
767
939
  };
940
+ // Check for browser environment
941
+ if (!isBrowser()) {
942
+ throw new Error('[falling-animation] Fireworks requires a browser environment. ' +
943
+ 'If using SSR (Next.js, Nuxt, etc.), make sure to only initialize on the client side.');
944
+ }
768
945
  // Resolve container
769
946
  let container;
770
947
  if (!options.container) {
@@ -787,130 +964,313 @@ class Fireworks {
787
964
  particleLifetime: (_g = options.particleLifetime) !== null && _g !== void 0 ? _g : DEFAULTS.particleLifetime,
788
965
  gravity: (_h = options.gravity) !== null && _h !== void 0 ? _h : DEFAULTS.gravity,
789
966
  trail: (_j = options.trail) !== null && _j !== void 0 ? _j : DEFAULTS.trail,
790
- autoStart: (_k = options.autoStart) !== null && _k !== void 0 ? _k : DEFAULTS.autoStart,
791
- 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
792
970
  };
793
- this.createWrapper();
971
+ // Create canvas renderer
972
+ this.renderer = new CanvasRenderer(container, this.options.zIndex);
794
973
  this.setupResizeHandler();
795
974
  if (this.options.autoStart) {
796
975
  this.start();
797
976
  }
798
977
  }
799
- createWrapper() {
800
- this.wrapper = document.createElement('div');
801
- this.wrapper.className = 'fireworks-wrapper';
802
- Object.assign(this.wrapper.style, {
803
- position: this.options.container === document.body ? 'fixed' : 'absolute',
804
- top: '0',
805
- left: '0',
806
- width: '100%',
807
- height: '100%',
808
- overflow: 'hidden',
809
- pointerEvents: 'none',
810
- zIndex: String(this.options.zIndex)
811
- });
812
- if (this.options.container !== document.body) {
813
- const containerPosition = getComputedStyle(this.options.container).position;
814
- if (containerPosition === 'static') {
815
- this.options.container.style.position = 'relative';
816
- }
817
- }
818
- this.options.container.appendChild(this.wrapper);
819
- }
820
978
  setupResizeHandler() {
821
979
  this.resizeHandler = throttle(() => {
822
- // Handle resize if needed
980
+ if (this.renderer) {
981
+ this.renderer.resize();
982
+ }
823
983
  }, 200);
824
984
  window.addEventListener('resize', this.resizeHandler);
825
985
  }
826
986
  getContainerSize() {
827
- return {
828
- width: this.options.container.clientWidth,
829
- height: this.options.container.clientHeight
830
- };
831
- }
832
- createParticleElement(particle) {
833
- const element = document.createElement('div');
834
- element.className = 'firework-particle';
835
- Object.assign(element.style, {
836
- position: 'absolute',
837
- left: '0',
838
- top: '0',
839
- width: `${particle.size}px`,
840
- height: `${particle.size}px`,
841
- backgroundColor: particle.color,
842
- borderRadius: '50%',
843
- boxShadow: `0 0 ${particle.size * 2}px ${particle.color}`,
844
- opacity: String(particle.opacity),
845
- pointerEvents: 'none',
846
- willChange: 'transform, opacity',
847
- transform: `translate3d(${particle.x}px, ${particle.y}px, 0)`
848
- });
849
- return element;
987
+ if (this.renderer) {
988
+ return this.renderer.getSize();
989
+ }
990
+ return { width: 0, height: 0 };
850
991
  }
851
992
  launchRocket() {
852
993
  const { width, height } = this.getContainerSize();
994
+ if (width === 0 || height === 0)
995
+ return;
853
996
  const color = this.options.colors[Math.floor(Math.random() * this.options.colors.length)];
854
997
  // Random explosion height: 10% to 90% from top of screen
855
998
  const targetY = randomRange(height * 0.1, height * 0.9);
856
999
  const distanceToTravel = height - targetY;
857
1000
  // Calculate time based on distance and speed
858
1001
  const rocketSpeed = randomFromRange(this.options.rocketSpeed);
859
- const travelTime = distanceToTravel / (rocketSpeed * 0.1); // approximate time in ms
1002
+ const travelTime = distanceToTravel / (rocketSpeed * 0.1);
860
1003
  const particle = {
861
1004
  id: generateId(),
862
- element: null,
863
1005
  x: randomRange(width * 0.1, width * 0.9),
864
- y: height + 10, // Start slightly below viewport
1006
+ y: height + 10,
865
1007
  vx: randomRange(-0.5, 0.5),
866
1008
  vy: -rocketSpeed,
867
1009
  size: 4,
868
1010
  opacity: 1,
869
1011
  color,
870
1012
  age: 0,
871
- maxAge: Math.min(travelTime, 3000), // Cap at 3 seconds
1013
+ maxAge: Math.min(travelTime, 3000),
872
1014
  phase: 'rocket',
873
1015
  gravity: 0,
874
- targetY // Store target explosion height
1016
+ targetY,
1017
+ stage: 0
875
1018
  };
876
- particle.element = this.createParticleElement(particle);
877
1019
  this.particles.push(particle);
878
- if (this.wrapper) {
879
- this.wrapper.appendChild(particle.element);
880
- }
881
1020
  }
882
1021
  explode(rocket) {
883
1022
  const particleCount = this.options.particlesPerExplosion;
884
1023
  const color = rocket.color;
885
- // Create explosion particles spreading in all directions
886
- for (let i = 0; i < particleCount; i++) {
887
- // Spread evenly in 360 degrees
888
- 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);
889
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;
890
1199
  const particle = {
891
1200
  id: generateId(),
892
- element: null,
893
1201
  x: rocket.x,
894
1202
  y: rocket.y,
895
1203
  vx: Math.cos(angle) * speed,
896
1204
  vy: Math.sin(angle) * speed,
1205
+ // Normal size for primary particles
897
1206
  size: randomFromRange(this.options.particleSize),
898
1207
  opacity: 1,
899
1208
  color: this.getVariantColor(color),
900
1209
  age: 0,
901
1210
  maxAge: randomFromRange(this.options.particleLifetime),
902
1211
  phase: 'explosion',
903
- 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
904
1242
  };
905
- particle.element = this.createParticleElement(particle);
906
1243
  this.particles.push(particle);
907
- if (this.wrapper) {
908
- this.wrapper.appendChild(particle.element);
909
- }
910
1244
  }
911
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
+ }
912
1273
  getVariantColor(baseColor) {
913
- // Sometimes return white or a lighter variant for sparkle effect
914
1274
  if (Math.random() < 0.2) {
915
1275
  return '#ffffff';
916
1276
  }
@@ -923,7 +1283,6 @@ class Fireworks {
923
1283
  const index = this.particles.indexOf(particle);
924
1284
  if (index > -1) {
925
1285
  this.particles.splice(index, 1);
926
- particle.element.remove();
927
1286
  }
928
1287
  }
929
1288
  /** Start the fireworks */
@@ -945,10 +1304,10 @@ class Fireworks {
945
1304
  }
946
1305
  /** Clear all particles but keep running */
947
1306
  clear() {
948
- for (const particle of this.particles) {
949
- particle.element.remove();
950
- }
951
1307
  this.particles = [];
1308
+ if (this.renderer) {
1309
+ this.renderer.clear();
1310
+ }
952
1311
  }
953
1312
  /** Launch a single firework manually */
954
1313
  launch() {
@@ -978,6 +1337,8 @@ class Fireworks {
978
1337
  this.options.particleLifetime = newOptions.particleLifetime;
979
1338
  if (newOptions.gravity !== undefined)
980
1339
  this.options.gravity = newOptions.gravity;
1340
+ if (newOptions.explosionPattern)
1341
+ this.options.explosionPattern = newOptions.explosionPattern;
981
1342
  }
982
1343
  /** Get particle count */
983
1344
  getParticleCount() {
@@ -991,9 +1352,9 @@ class Fireworks {
991
1352
  destroy() {
992
1353
  this.stop();
993
1354
  this.clear();
994
- if (this.wrapper) {
995
- this.wrapper.remove();
996
- this.wrapper = null;
1355
+ if (this.renderer) {
1356
+ this.renderer.destroy();
1357
+ this.renderer = null;
997
1358
  }
998
1359
  if (this.resizeHandler) {
999
1360
  window.removeEventListener('resize', this.resizeHandler);
@@ -1016,4 +1377,5 @@ exports.Fireworks = Fireworks;
1016
1377
  exports.animations = animations;
1017
1378
  exports.default = FallingAnimation;
1018
1379
  exports.getAnimation = getAnimation;
1380
+ exports.isBrowser = isBrowser;
1019
1381
  //# sourceMappingURL=falling-animation.cjs.map