1import { Controller } from "@hotwired/stimulus"; 2import "number-flow"; 3import { continuous } from "number-flow"; 4 5export default class extends Controller { 6 static values = { 7 start: { type: Number, default: 0 }, // Start value of the animation 8 end: { type: Number, default: 0 }, // End value of the animation 9 duration: { type: Number, default: 700 }, // Duration of the animation for each number change 10 trigger: { type: String, default: "viewport" }, // Trigger for the animation (load, viewport, manual) 11 prefix: String, // Prefix for the number 12 suffix: String, // Suffix for the number 13 formatOptions: String, // Go here to learn more: https://number-flow.barvian.me/vanilla#properties 14 trend: Number, // Trend for the animation 15 realtime: { type: Boolean, default: false }, // If true, uses interval for timed updates 16 updateInterval: { type: Number, default: 1000 }, // Interval in ms for realtime updates 17 continuous: { type: Boolean, default: true }, // If true, uses continuous plugin for smooth transitions 18 spinEasing: { type: String, default: "ease-in-out" }, // Easing for digit spin animations 19 transformEasing: { type: String, default: "ease-in-out" }, // Easing for layout transforms 20 opacityEasing: { type: String, default: "ease-out" }, // Easing for fade in/out 21 }; 22 23 connect() { 24 this.element.innerHTML = "<number-flow></number-flow>"; 25 this.flow = this.element.querySelector("number-flow"); 26 this.currentValue = this.startValue || 0; 27 28 // Set initial properties from data attributes 29 if (this.hasPrefixValue) this.flow.numberPrefix = this.prefixValue; 30 if (this.hasSuffixValue) this.flow.numberSuffix = this.suffixValue; 31 32 if (this.hasFormatOptionsValue) { 33 try { 34 this.flow.format = JSON.parse(this.formatOptionsValue); 35 } catch (e) { 36 console.error("Error parsing formatOptions JSON:", e); 37 // Apply default or no formatting if parsing fails 38 } 39 } 40 41 if (this.hasTrendValue) { 42 this.flow.trend = this.trendValue; 43 } else { 44 // Default trend for continuous plugin if not specified 45 this.flow.trend = Math.sign(this.endValue - this.currentValue) || 1; 46 } 47 48 // Initialize with start value without animation for non-realtime, or let realtime handle first update 49 if (!this.realtimeValue) { 50 this.flow.update(this.currentValue); 51 } 52 53 // Configure timing with customizable easing 54 this.configureTimings(); 55 56 // Apply continuous plugin if enabled 57 if (this.continuousValue) { 58 this.flow.plugins = [continuous]; 59 } 60 61 this.handleTrigger(); 62 } 63 64 configureTimings() { 65 const animationDuration = this.durationValue || 700; 66 67 // Configure spin timing (for digit animations) 68 this.flow.spinTiming = { 69 duration: animationDuration, 70 easing: this.spinEasingValue, 71 }; 72 73 // Configure transform timing (for layout changes) 74 this.flow.transformTiming = { 75 duration: animationDuration, 76 easing: this.transformEasingValue, 77 }; 78 79 // Configure opacity timing (for fade effects) 80 this.flow.opacityTiming = { 81 duration: 350, 82 easing: this.opacityEasingValue, 83 }; 84 } 85 86 handleTrigger() { 87 const trigger = this.triggerValue || "viewport"; 88 89 switch (trigger) { 90 case "load": 91 this.startAnimation(); 92 break; 93 case "viewport": 94 this.observeViewport(); 95 break; 96 case "manual": 97 // Don't auto-start for manual trigger 98 break; 99 default: 100 this.startAnimation(); 101 break; 102 } 103 } 104 105 startAnimation() { 106 if (this.realtimeValue) { 107 this.flow.update(this.currentValue); // Initial update for realtime 108 this.timerInterval = setInterval(() => { 109 this.tick(); 110 }, this.updateIntervalValue); 111 } else { 112 this.animateToEnd(); 113 } 114 } 115 116 tick() { 117 const step = Math.sign(this.endValue - this.startValue) || (this.startValue > this.endValue ? -1 : 1); 118 this.currentValue += step; 119 this.flow.update(this.currentValue); 120 121 if ((step > 0 && this.currentValue >= this.endValue) || (step < 0 && this.currentValue <= this.endValue)) { 122 clearInterval(this.timerInterval); 123 } 124 } 125 126 animateToEnd() { 127 if (!this.flow) return; 128 // For non-realtime, the duration value from HTML (or default) applies to the whole animation. 129 // Reconfigure timings if duration was specifically set for the full range 130 if (!this.realtimeValue && this.hasDurationValue) { 131 const overallDuration = this.durationValue || 2000; 132 133 this.flow.spinTiming = { 134 duration: overallDuration, 135 easing: this.spinEasingValue, 136 }; 137 138 this.flow.transformTiming = { 139 duration: overallDuration, 140 easing: this.transformEasingValue, 141 }; 142 143 this.flow.opacityTiming = { 144 duration: 350, 145 easing: this.opacityEasingValue, 146 }; 147 } 148 this.flow.update(this.endValue); 149 } 150 151 observeViewport() { 152 this.observer = new IntersectionObserver((entries) => {
153 entries.forEach((entry) => { 154 if (entry.isIntersecting) { 155 this.startAnimation(); 156 this.observer.unobserve(this.element); 157 } 158 }); 159 }); 160 161 this.observer.observe(this.element); 162 } 163 164 // Method for manual triggering (can be called externally) 165 triggerAnimation() { 166 // Reset to start value first 167 this.currentValue = this.startValue || 0; 168 this.flow.update(this.currentValue); 169 170 // Small delay to ensure reset is visible 171 setTimeout(() => { 172 this.startAnimation(); 173 }, 50); 174 } 175 176 disconnect() { 177 if (this.timerInterval) { 178 clearInterval(this.timerInterval); 179 } 180 if (this.flow && typeof this.flow.destroy === "function") { 181 // Assuming number-flow might have a cleanup/destroy method 182 // If not, this line might need adjustment based on the library's API 183 // For now, we'll assume it doesn't to prevent errors if 'destroy' doesn't exist. 184 } 185 if (this.observer) { 186 this.observer.disconnect(); 187 } 188 } 189};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.