@thinkpixellab-public/px-vue 4.1.30 → 4.1.32

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,14 +45,14 @@ export default {
45
45
  const rectEls = svg?.querySelectorAll(':scope > rect');
46
46
  const imageEls = svg?.querySelectorAll(':scope > image');
47
47
 
48
- const marginPx = (this.percentageMargin / 100) * svg.clientWidth;
49
- const scale = (svg.clientWidth - marginPx * 2) / svg.clientWidth;
48
+ const marginPx = (this.percentageMargin / 100) * svg?.clientWidth;
49
+ const scale = (svg?.clientWidth - marginPx * 2) / svg?.clientWidth;
50
50
 
51
51
  // the second rect is the qrcode
52
52
  const qrRect = rectEls?.[1];
53
53
  const qrImage = imageEls?.[0];
54
54
 
55
- if (!qrRect || !qrImage) {
55
+ if (!qrRect || !qrImage || !svg) {
56
56
  if (retryCount < MAX_RETRIES) {
57
57
  setTimeout(() => {
58
58
  this.setScale(retryCount + 1);
@@ -69,86 +69,106 @@ export default {
69
69
  qrRect.style.transform = `scale(${scale})`;
70
70
  qrImage.style.transform = `scale(${scale})`;
71
71
  },
72
- },
73
- mounted() {
74
- this.size = Math.min(this.$el.clientWidth, this.$el.clientHeight);
75
-
76
- const { margin, ...rest } = this.qrCodeConfig || {};
77
-
78
- // margin is a percentage of the svg width
79
- this.percentageMargin = margin ? Math.min(Math.max(0, margin), 100) : 0;
80
-
81
- const options = {
82
- width: this.size,
83
- height: this.size,
84
- image: this.imageSettings?.src || null,
85
- imageOptions: {
86
- hideBackgroundDots: false,
87
- imageSize: this.imageSettings?.size || 0.1,
88
- margin: 0,
89
- crossOrigin: 'anonymous',
90
- },
91
- data: this.content,
92
- ...rest,
93
- type: 'svg',
94
- };
95
-
96
- if (!this.percentageMargin) {
97
- this.isRendered = true;
98
- }
99
-
100
- this.qrCode = new QRCodeStyling(options);
72
+ setupQRCode() {
73
+ this.size = Math.min(this.$el.clientWidth, this.$el.clientHeight);
74
+
75
+ if (this.size <= 0) {
76
+ // setup a size observer to wait for a size, if size is 0 the qr code throws an exception
77
+ this.setupSizeObserver = new ResizeObserver(entries => {
78
+ const size = Math.min(this.$el.clientWidth, this.$el.clientHeight);
79
+ if (size > 0) {
80
+ this.setupSizeObserver.disconnect();
81
+ this.setupSizeObserver = null;
82
+ this.setupQRCode();
83
+ }
84
+ });
101
85
 
102
- this.onSizeChange = () => {
103
- const containerSize = Math.min(this.$el.clientWidth, this.$el.clientHeight);
104
- this.size = containerSize;
86
+ this.setupSizeObserver.observe(this.$el);
105
87
 
106
- if (!this.qrCode._qr) {
107
88
  return;
108
89
  }
109
90
 
110
- // 1. Get module count
111
- const moduleCount = this.qrCode._qr.getModuleCount();
91
+ const { margin, ...rest } = this.qrCodeConfig || {};
92
+
93
+ // margin is a percentage of the svg width
94
+ this.percentageMargin = margin ? Math.min(Math.max(0, margin), 100) : 0;
95
+
96
+ const options = {
97
+ width: this.size,
98
+ height: this.size,
99
+ image: this.imageSettings?.src || null,
100
+ imageOptions: {
101
+ hideBackgroundDots: false,
102
+ imageSize: this.imageSettings?.size || 0.1,
103
+ margin: 0,
104
+ crossOrigin: 'anonymous',
105
+ },
106
+ data: this.content,
107
+ ...rest,
108
+ type: 'svg',
109
+ };
112
110
 
113
- const naturalModSize = containerSize / moduleCount;
111
+ if (!this.percentageMargin) {
112
+ this.isRendered = true;
113
+ }
114
114
 
115
- // 2. the qr code lib uses integer pixel sizes, so we need to round down
116
- const moduleSize = Math.floor(naturalModSize);
115
+ this.qrCode = new QRCodeStyling(options);
117
116
 
118
- // 3. recalculate actual size to position the qr code in the top left corner
119
- const qrSize = moduleCount * moduleSize;
117
+ this.onSizeChange = () => {
118
+ const containerSize = Math.min(this.$el.clientWidth, this.$el.clientHeight);
119
+ this.size = containerSize;
120
120
 
121
- // 4. Update QR options
122
- options.width = qrSize;
123
- options.height = qrSize;
121
+ if (!this.qrCode._qr) {
122
+ return;
123
+ }
124
124
 
125
- this.qrCode.update(options);
125
+ // 1. Get module count
126
+ const moduleCount = this.qrCode._qr.getModuleCount();
126
127
 
127
- this.$nextTick(() => {
128
- this.setScale();
129
- });
130
- };
128
+ const naturalModSize = containerSize / moduleCount;
131
129
 
132
- this.resizeObserver = new ResizeObserver(this.onSizeChange);
133
- this.resizeObserver.observe(this.$el);
130
+ // 2. the qr code lib uses integer pixel sizes, so we need to round down
131
+ const moduleSize = Math.floor(naturalModSize);
134
132
 
135
- this.$nextTick(() => {
136
- this.qrCode.append(this.$refs.qrCode);
133
+ // 3. recalculate actual size to position the qr code in the top left corner
134
+ const qrSize = moduleCount * moduleSize;
137
135
 
138
- this.onSizeChange();
136
+ // 4. Update QR options
137
+ options.width = qrSize;
138
+ options.height = qrSize;
139
139
 
140
- if (this.percentageMargin) {
141
- this.qrCode.getRawData('svg').then(() => {
142
- // QR code has been rendered
143
- this.$nextTick(() => {
144
- this.setScale();
145
- });
140
+ this.qrCode.update(options);
141
+
142
+ this.$nextTick(() => {
143
+ this.setScale();
146
144
  });
147
- }
148
- });
145
+ };
146
+
147
+ this.resizeObserver = new ResizeObserver(this.onSizeChange);
148
+ this.resizeObserver.observe(this.$el);
149
+
150
+ this.$nextTick(() => {
151
+ this.qrCode.append(this.$refs.qrCode);
152
+
153
+ this.onSizeChange();
154
+
155
+ if (this.percentageMargin) {
156
+ this.qrCode.getRawData('svg').then(() => {
157
+ // QR code has been rendered
158
+ this.$nextTick(() => {
159
+ this.setScale();
160
+ });
161
+ });
162
+ }
163
+ });
164
+ },
165
+ },
166
+ mounted() {
167
+ this.setupQRCode();
149
168
  },
150
169
  beforeUnmount() {
151
170
  this.resizeObserver?.disconnect();
171
+ this.setupSizeObserver?.disconnect();
152
172
  },
153
173
  };
154
174
  </script>
@@ -51,8 +51,12 @@ export const loadGsap = async () => {
51
51
  export const loadScrollTrigger = async () => {
52
52
  if (!scrollTriggerPromise) {
53
53
  scrollTriggerPromise = (async () => {
54
- const [gsap, { ScrollTrigger }] = await Promise.all([loadGsap(), import('gsap/ScrollTrigger')]);
55
- gsap.registerPlugin(ScrollTrigger);
54
+ const [gsap, { ScrollTrigger }, { CSSPlugin }] = await Promise.all([
55
+ loadGsap(),
56
+ import('gsap/ScrollTrigger'),
57
+ import('gsap/CSSPlugin')
58
+ ]);
59
+ gsap.registerPlugin(ScrollTrigger, CSSPlugin);
56
60
  return { gsap, ScrollTrigger };
57
61
  })();
58
62
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  /**
4
4
  * Lazy loading utilities for Lenis smooth scrolling
5
- *
5
+ *
6
6
  * These functions provide race-condition-safe loading and initialization of Lenis.
7
7
  * The instance is shared across all components for consistent scroll behavior.
8
8
  */
@@ -12,13 +12,13 @@ let lenisInstancePromise = null;
12
12
 
13
13
  /**
14
14
  * Loads the Lenis class lazily
15
- *
15
+ *
16
16
  * @returns {Promise} Promise that resolves to the Lenis constructor
17
- *
17
+ *
18
18
  * @example
19
19
  * // Direct usage (not recommended - use useLenis composable instead)
20
20
  * import { loadLenis } from '@thinkpixellab-public/px-vue/composables/loadLenis';
21
- *
21
+ *
22
22
  * const Lenis = await loadLenis();
23
23
  * const customLenis = new Lenis({
24
24
  * duration: 2,
@@ -34,32 +34,32 @@ export const loadLenis = async () => {
34
34
 
35
35
  /**
36
36
  * Loads a shared Lenis instance with default configuration
37
- *
37
+ *
38
38
  * Returns the same instance across all calls for consistent scroll behavior.
39
39
  * Starts with autoRaf enabled - useScrollAnimation will take over RAF control when needed.
40
- *
40
+ *
41
41
  * @returns {Promise} Promise that resolves to the shared Lenis instance
42
- *
42
+ *
43
43
  * @example
44
44
  * // Direct usage (not recommended - use useLenis composable instead)
45
45
  * import { loadLenisInstance } from '@thinkpixellab-public/px-vue/composables/loadLenis';
46
- *
46
+ *
47
47
  * const lenis = await loadLenisInstance();
48
- *
48
+ *
49
49
  * // smooth scroll to element
50
50
  * lenis.scrollTo('#section-2', { duration: 1.5 });
51
- *
51
+ *
52
52
  * // listen to scroll events
53
53
  * lenis.on('scroll', (e) => {
54
54
  * console.log('Scroll position:', e.scroll);
55
55
  * });
56
- *
56
+ *
57
57
  * @example
58
58
  * // Configuration details
59
59
  * // The shared instance is created with:
60
60
  * // {
61
61
  * // smoothWheel: false, // disabled for better performance
62
- * // smoothTouch: false, // disabled for mobile compatibility
62
+ * // smoothTouch: false, // disabled for mobile compatibility
63
63
  * // autoRaf: true // auto RAF until ScrollTrigger takes over
64
64
  * // }
65
65
  */
@@ -2,10 +2,10 @@
2
2
  import { loadGsap } from './loadGsap';
3
3
 
4
4
  /**
5
- * Ensures GSAP is loaded and available globally.
6
- * After calling this composable, you can use gsap directly as a global.
5
+ * Provides access to the GSAP library with lazy loading.
6
+ * Components should handle their own animation cleanup.
7
7
  *
8
- * @returns {Object} Object with ready promise that resolves when GSAP is loaded
8
+ * @returns {Object} Object with getGsap loader function
9
9
  *
10
10
  * @example
11
11
  * // Basic usage in a Vue component
@@ -13,12 +13,12 @@ import { loadGsap } from './loadGsap';
13
13
  *
14
14
  * export default {
15
15
  * setup() {
16
- * const { ready } = useGsap();
16
+ * const { getGsap } = useGsap();
17
17
  *
18
18
  * onMounted(async () => {
19
- * await ready;
19
+ * const gsap = await getGsap();
20
20
  *
21
- * // gsap is now available globally
21
+ * // animate an element
22
22
  * gsap.to('.my-element', {
23
23
  * duration: 1,
24
24
  * x: 100,
@@ -35,12 +35,13 @@ import { loadGsap } from './loadGsap';
35
35
  *
36
36
  * export default {
37
37
  * setup() {
38
- * useGsap(); // ensures gsap loads
38
+ * const { getGsap } = useGsap();
39
39
  * const myElement = ref(null);
40
40
  * let animation = null;
41
41
  *
42
- * onMounted(() => {
43
- * // gsap is available globally
42
+ * onMounted(async () => {
43
+ * const gsap = await getGsap();
44
+ *
44
45
  * animation = gsap.to(myElement.value, {
45
46
  * duration: 2,
46
47
  * rotation: 360,
@@ -57,25 +58,11 @@ import { loadGsap } from './loadGsap';
57
58
  * return { myElement };
58
59
  * }
59
60
  * }
60
- *
61
- * @example
62
- * // Immediate usage if you know GSAP is already loaded
63
- * import { useGsap } from '@thinkpixellab-public/px-vue/composables/useGsap';
64
- *
65
- * export default {
66
- * setup() {
67
- * useGsap(); // triggers load if needed
68
- *
69
- * // can use gsap immediately in most cases
70
- * const tl = gsap.timeline();
71
- * }
72
- * }
73
61
  */
74
62
  export const useGsap = () => {
75
63
  if (process.server) {
76
- return { ready: Promise.resolve() };
64
+ return { getGsap: () => null };
77
65
  }
78
66
 
79
- const ready = loadGsap();
80
- return { ready };
67
+ return { getGsap: loadGsap };
81
68
  };
@@ -3,74 +3,74 @@ import { loadLenisInstance } from './loadLenis';
3
3
 
4
4
  /**
5
5
  * Vue composable for using Lenis smooth scrolling
6
- *
6
+ *
7
7
  * Provides access to a shared Lenis instance with autoRaf enabled.
8
8
  * Perfect for components that need smooth scrolling without ScrollTrigger integration.
9
- *
9
+ *
10
10
  * @returns {Object} Object with getLenis loader function
11
- *
11
+ *
12
12
  * @example
13
13
  * // Basic usage for smooth scrolling
14
14
  * import { useLenis } from '@thinkpixellab-public/px-vue/composables/useLenis';
15
- *
15
+ *
16
16
  * export default {
17
17
  * setup() {
18
18
  * const { getLenis } = useLenis();
19
- *
19
+ *
20
20
  * onMounted(async () => {
21
21
  * const lenis = await getLenis();
22
- *
22
+ *
23
23
  * // scroll to top smoothly
24
24
  * lenis.scrollTo(0);
25
- *
25
+ *
26
26
  * // scroll to element
27
27
  * lenis.scrollTo('#my-section');
28
- *
28
+ *
29
29
  * // scroll to specific position
30
30
  * lenis.scrollTo(500, { duration: 2 });
31
31
  * });
32
32
  * }
33
33
  * }
34
- *
34
+ *
35
35
  * @example
36
36
  * // Listen to scroll events for parallax
37
37
  * import { ref, onMounted } from 'vue';
38
38
  * import { useLenis } from '@thinkpixellab-public/px-vue/composables/useLenis';
39
- *
39
+ *
40
40
  * export default {
41
41
  * setup() {
42
42
  * const { getLenis } = useLenis();
43
43
  * const parallaxOffset = ref(0);
44
- *
44
+ *
45
45
  * onMounted(async () => {
46
46
  * const lenis = await getLenis();
47
- *
47
+ *
48
48
  * lenis.on('scroll', (e) => {
49
49
  * parallaxOffset.value = e.scroll * 0.5;
50
50
  * });
51
51
  * });
52
- *
52
+ *
53
53
  * return { parallaxOffset };
54
54
  * }
55
55
  * }
56
- *
56
+ *
57
57
  * @example
58
58
  * // Custom scroll behavior
59
59
  * import { useLenis } from '@thinkpixellab-public/px-vue/composables/useLenis';
60
- *
60
+ *
61
61
  * export default {
62
62
  * setup() {
63
63
  * const { getLenis } = useLenis();
64
- *
64
+ *
65
65
  * const scrollToSection = async (selector) => {
66
66
  * const lenis = await getLenis();
67
- *
67
+ *
68
68
  * lenis.scrollTo(selector, {
69
69
  * duration: 1.5,
70
70
  * easing: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
71
71
  * });
72
72
  * };
73
- *
73
+ *
74
74
  * return { scrollToSection };
75
75
  * }
76
76
  * }
@@ -80,7 +80,7 @@ export const useLenis = () => {
80
80
  return { getLenis: () => null };
81
81
  }
82
82
 
83
- return {
84
- getLenis: loadLenisInstance
83
+ return {
84
+ getLenis: loadLenisInstance,
85
85
  };
86
- };
86
+ };
@@ -5,107 +5,104 @@ import { loadLenisInstance } from './loadLenis';
5
5
  let scrollTriggerPromise = null;
6
6
 
7
7
  /**
8
- * Vue composable for scroll-triggered animations with GSAP ScrollTrigger and Lenis
9
- *
10
- * Ensures GSAP, ScrollTrigger, and Lenis are loaded and configured for optimal scroll animations.
11
- * Automatically manages RAF control transfer from Lenis to GSAP for optimal performance.
12
- * After ready resolves, gsap and ScrollTrigger are available globally.
13
- *
14
- * @returns {Object} Object with ready promise and getLenis function
15
- *
16
- * @example
17
- * // Basic scroll-triggered animation
18
- * import { useScrollAnimation } from '@thinkpixellab-public/px-vue/composables/useScrollAnimations';
19
- *
20
- * export default {
21
- * setup() {
22
- * const { ready } = useScrollAnimation();
23
- *
24
- * onMounted(async () => {
25
- * await ready;
26
- *
27
- * // gsap and ScrollTrigger are now available globally
28
- * gsap.to('.fade-in', {
29
- * opacity: 1,
30
- * y: 0,
31
- * scrollTrigger: {
32
- * trigger: '.fade-in',
33
- * start: 'top 80%',
34
- * end: 'bottom 20%',
35
- * scrub: true
36
- * }
37
- * });
38
- * });
39
- * }
40
- * }
41
- *
42
- * @example
43
- * // Parallax scrolling with Lenis integration
44
- * import { useScrollAnimation } from '@thinkpixellab-public/px-vue/composables/useScrollAnimations';
45
- *
46
- * export default {
47
- * setup() {
48
- * const { ready, getLenis } = useScrollAnimation();
49
- *
50
- * onMounted(async () => {
51
- * await ready;
52
- * const lenis = await getLenis();
53
- *
54
- * // smooth scroll to section
55
- * lenis.scrollTo('#section-2', { duration: 2 });
56
- *
57
- * // parallax background with global gsap
58
- * gsap.to('.bg-parallax', {
59
- * yPercent: -50,
60
- * ease: 'none',
61
- * scrollTrigger: {
62
- * trigger: '.parallax-section',
63
- * start: 'top bottom',
64
- * end: 'bottom top',
65
- * scrub: true
66
- * }
67
- * });
68
- * });
69
- * }
70
- * }
71
- *
72
- * @example
73
- * // Complex timeline with ScrollTrigger
74
- * import { ref, onMounted } from 'vue';
75
- * import { useScrollAnimation } from '@thinkpixellab-public/px-vue/composables/useScrollAnimations';
76
- *
77
- * export default {
78
- * setup() {
79
- * const { ready } = useScrollAnimation();
80
- * const container = ref(null);
81
- *
82
- * onMounted(async () => {
83
- * await ready;
84
- *
85
- * // gsap is available globally
86
- * const tl = gsap.timeline({
87
- * scrollTrigger: {
88
- * trigger: container.value,
89
- * start: 'top center',
90
- * end: 'bottom center',
91
- * scrub: 1,
92
- * pin: true
93
- * }
94
- * });
95
- *
96
- * tl.from('.item-1', { x: -100, opacity: 0 })
97
- * .from('.item-2', { x: 100, opacity: 0 }, '-=0.5')
98
- * .from('.item-3', { y: 100, opacity: 0 }, '-=0.3');
99
- * });
100
- *
101
- * return { container };
102
- * }
103
- * }
104
- */
8
+ Vue composable for scroll-triggered animations with GSAP ScrollTrigger and Lenis
9
+
10
+ Provides integrated GSAP, ScrollTrigger, and Lenis for complex scroll animations.
11
+ Automatically manages RAF control transfer from Lenis to GSAP for optimal performance.
12
+
13
+ @returns {Object} Object with getGsap, getScrollTrigger, and getLenis loader functions
14
+
15
+ @example
16
+ // Basic scroll-triggered animation
17
+ import { useScrollAnimation } from '@thinkpixellab-public/px-vue/composables/useScrollAnimations';
18
+
19
+ export default {
20
+ setup() {
21
+ const { getGsap, getScrollTrigger } = useScrollAnimation();
22
+
23
+ onMounted(async () => {
24
+ const [gsap, ScrollTrigger] = await Promise.all([getGsap(), getScrollTrigger()]);
25
+
26
+ gsap.to('.fade-in', {
27
+ opacity: 1,
28
+ y: 0,
29
+ scrollTrigger: {
30
+ trigger: '.fade-in',
31
+ start: 'top 80%',
32
+ end: 'bottom 20%',
33
+ scrub: true
34
+ }
35
+ });
36
+ });
37
+ }
38
+ }
39
+
40
+ @example
41
+ // Parallax scrolling with Lenis integration
42
+ import { useScrollAnimation } from '@thinkpixellab-public/px-vue/composables/useScrollAnimations';
43
+
44
+ export default {
45
+ setup() {
46
+ const { getGsap, getLenis } = useScrollAnimation();
47
+
48
+ onMounted(async () => {
49
+ const [gsap, lenis] = await Promise.all([getGsap(), getLenis()]);
50
+
51
+ // smooth scroll to section
52
+ lenis.scrollTo('#section-2', { duration: 2 });
53
+
54
+ // parallax background
55
+ gsap.to('.bg-parallax', {
56
+ yPercent: -50,
57
+ ease: 'none',
58
+ scrollTrigger: {
59
+ trigger: '.parallax-section',
60
+ start: 'top bottom',
61
+ end: 'bottom top',
62
+ scrub: true
63
+ }
64
+ });
65
+ });
66
+ }
67
+ }
68
+
69
+ @example
70
+ // Complex timeline with ScrollTrigger
71
+ import { ref, onMounted } from 'vue';
72
+ import { useScrollAnimation } from '@thinkpixellab-public/px-vue/composables/useScrollAnimations';
73
+
74
+ export default {
75
+ setup() {
76
+ const { getGsap } = useScrollAnimation();
77
+ const container = ref(null);
78
+
79
+ onMounted(async () => {
80
+ const gsap = await getGsap();
81
+
82
+ const tl = gsap.timeline({
83
+ scrollTrigger: {
84
+ trigger: container.value,
85
+ start: 'top center',
86
+ end: 'bottom center',
87
+ scrub: 1,
88
+ pin: true
89
+ }
90
+ });
91
+
92
+ tl.from('.item-1', { x: -100, opacity: 0 })
93
+ .from('.item-2', { x: 100, opacity: 0 }, '-=0.5')
94
+ .from('.item-3', { y: 100, opacity: 0 }, '-=0.3');
95
+ });
96
+
97
+ return { container };
98
+ }
99
+ }
100
+ **/
105
101
  export const useScrollAnimation = () => {
106
102
  if (process.server) {
107
103
  return {
108
- ready: Promise.resolve(),
104
+ getGsap: () => null,
105
+ getScrollTrigger: () => null,
109
106
  getLenis: () => null,
110
107
  };
111
108
  }
@@ -121,14 +118,21 @@ export const useScrollAnimation = () => {
121
118
  // Take over RAF control from Lenis
122
119
  lenis.stop();
123
120
 
121
+ // Disable autoRaf permanently - we'll drive it with GSAP
122
+ lenis.options.autoRaf = false;
123
+
124
124
  lenis.on('scroll', ScrollTrigger.update);
125
+
125
126
  gsap.ticker.add(time => {
126
127
  lenis.raf(time * 1000);
127
128
  });
129
+
128
130
  gsap.ticker.lagSmoothing(0);
129
131
 
130
- // ScrollTrigger is now available globally
131
- return { lenis };
132
+ // Re-enable scroll processing (but without autoRaf)
133
+ lenis.start();
134
+
135
+ return { gsap, ScrollTrigger, lenis };
132
136
  })();
133
137
  }
134
138
 
@@ -136,7 +140,8 @@ export const useScrollAnimation = () => {
136
140
  };
137
141
 
138
142
  return {
139
- ready: init().then(() => {}), // resolves when ScrollTrigger is ready
143
+ getGsap: async () => (await init()).gsap,
144
+ getScrollTrigger: async () => (await init()).ScrollTrigger,
140
145
  getLenis: async () => (await init()).lenis,
141
146
  };
142
147
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thinkpixellab-public/px-vue",
3
- "version": "4.1.30",
3
+ "version": "4.1.32",
4
4
  "description": "General purpose Vue components and helpers that can be used across projects.",
5
5
  "author": {
6
6
  "name": "Pixel Lab"
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Generate a Base64 PNG noise texture (transparent background).
3
+ *
4
+ * @param {number} width
5
+ * @param {number} height
6
+ * @param {Object} [opts]
7
+ * @param {number} [opts.seed=Date.now()] - RNG seed
8
+ * @param {number} [opts.coverage=0.35] - 0..1 overall density of speckles
9
+ * @param {number} [opts.darkAmount=0.5] - relative weight of dark specks
10
+ * @param {number} [opts.lightAmount=0.5] - relative weight of light specks
11
+ * @param {Array} [opts.darkColor=[0,0,0]] - [r,g,b] for dark specks (0–255)
12
+ * @param {Array} [opts.lightColor=[255,255,255]] - [r,g,b] for light specks
13
+ * @param {number} [opts.darkAlpha=0.22] - 0..1 alpha for dark specks
14
+ * @param {number} [opts.lightAlpha=0.18] - 0..1 alpha for light specks
15
+ * @param {number} [opts.jitter=0.08] - alpha variation per pixel
16
+ * @param {function} [opts.weightFn] - (x, y, r) => 0..1 density modulator
17
+ * @returns {string} data URL: "data:image/png;base64,..."
18
+ */
19
+ function createNoiseBase64(width, height, opts = {}) {
20
+ // Seeded RNG
21
+ function mulberry32(a) {
22
+ return function () {
23
+ let t = (a += 0x6d2b79f5);
24
+ t = Math.imul(t ^ (t >>> 15), t | 1);
25
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
26
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
27
+ };
28
+ }
29
+
30
+ const clamp255 = v => Math.max(0, Math.min(255, v | 0));
31
+
32
+ const {
33
+ seed = Date.now(),
34
+ coverage = 0.35,
35
+ darkAmount = 0.5,
36
+ lightAmount = 0.5,
37
+ darkColor = [0, 0, 0],
38
+ lightColor = [255, 255, 255],
39
+ darkAlpha = 0.22,
40
+ lightAlpha = 0.18,
41
+ jitter = 0.08,
42
+ weightFn,
43
+ } = opts;
44
+
45
+ const rng = mulberry32(seed >>> 0 || 1);
46
+
47
+ const canvas = document.createElement('canvas');
48
+ canvas.width = width;
49
+ canvas.height = height;
50
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
51
+
52
+ const img = ctx.createImageData(width, height);
53
+ const data = img.data;
54
+
55
+ // Transparent background (already zeroed out by default, but explicit for clarity)
56
+ for (let i = 0; i < data.length; i += 4) {
57
+ data[i + 0] = 0;
58
+ data[i + 1] = 0;
59
+ data[i + 2] = 0;
60
+ data[i + 3] = 0;
61
+ }
62
+
63
+ // Draw speckles
64
+ for (let y = 0; y < height; y++) {
65
+ for (let x = 0; x < width; x++) {
66
+ const idx = (y * width + x) * 4;
67
+ const r = rng();
68
+ const weight =
69
+ typeof weightFn === 'function' ? Math.max(0, Math.min(1, weightFn(x, y, r))) : 1;
70
+
71
+ // Should this pixel be noise at all?
72
+ if (r >= coverage * weight) continue;
73
+
74
+ // Decide dark vs light by relative weights
75
+ const total = Math.max(1e-6, darkAmount + lightAmount);
76
+ const darkShare = darkAmount / total;
77
+ const draw = rng() < darkShare ? 1 : 2;
78
+
79
+ const jitterSign = rng() < 0.5 ? -1 : 1;
80
+ const baseAlpha = draw === 1 ? darkAlpha : lightAlpha;
81
+ const alpha = Math.max(0, Math.min(1, baseAlpha + jitterSign * jitter * rng()));
82
+
83
+ const [srcR, srcG, srcB] = draw === 1 ? darkColor : lightColor;
84
+ const srcA = alpha;
85
+
86
+ // Direct write (since background is transparent)
87
+ data[idx + 0] = clamp255(srcR);
88
+ data[idx + 1] = clamp255(srcG);
89
+ data[idx + 2] = clamp255(srcB);
90
+ data[idx + 3] = clamp255(srcA * 255);
91
+ }
92
+ }
93
+
94
+ ctx.putImageData(img, 0, 0);
95
+ return canvas.toDataURL('image/png');
96
+ }
97
+
98
+ export { createNoiseBase64 };