@thinkpixellab-public/px-vue 4.1.29 → 4.1.31

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.
@@ -10,7 +10,7 @@
10
10
  <div
11
11
  class="px-qr-code"
12
12
  ref="qrCode"
13
- :style="{ width: size + 'px', height: size + 'px' }"
13
+ :style="{ width: size + 'px', height: size + 'px', opacity: isRendered ? 1 : 0 }"
14
14
  role="img"
15
15
  :aria-label="label || content"
16
16
  ></div>
@@ -20,8 +20,6 @@
20
20
  <script>
21
21
  import QRCodeStyling from 'qr-code-styling';
22
22
 
23
- // test
24
-
25
23
  export default {
26
24
  name: 'px-qr-code',
27
25
  props: {
@@ -32,11 +30,54 @@ export default {
32
30
  qrCodeConfig: { type: Object, default: null },
33
31
  },
34
32
  data() {
35
- return { size: 0 };
33
+ return { size: 0, isRendered: false, percentageMargin: 0 };
34
+ },
35
+ methods: {
36
+ setScale(retryCount = 0) {
37
+ if (!this.percentageMargin) {
38
+ this.isRendered = true;
39
+ return;
40
+ }
41
+
42
+ const MAX_RETRIES = 10;
43
+
44
+ const svg = this.$refs.qrCode?.querySelector('svg');
45
+ const rectEls = svg?.querySelectorAll(':scope > rect');
46
+ const imageEls = svg?.querySelectorAll(':scope > image');
47
+
48
+ const marginPx = (this.percentageMargin / 100) * svg.clientWidth;
49
+ const scale = (svg.clientWidth - marginPx * 2) / svg.clientWidth;
50
+
51
+ // the second rect is the qrcode
52
+ const qrRect = rectEls?.[1];
53
+ const qrImage = imageEls?.[0];
54
+
55
+ if (!qrRect || !qrImage) {
56
+ if (retryCount < MAX_RETRIES) {
57
+ setTimeout(() => {
58
+ this.setScale(retryCount + 1);
59
+ }, 25);
60
+ } else {
61
+ // Optionally, log a warning or handle the error
62
+ // console.warn('QR rect or image not found after maximum retries.');
63
+ }
64
+ return;
65
+ }
66
+
67
+ this.isRendered = true;
68
+
69
+ qrRect.style.transform = `scale(${scale})`;
70
+ qrImage.style.transform = `scale(${scale})`;
71
+ },
36
72
  },
37
73
  mounted() {
38
74
  this.size = Math.min(this.$el.clientWidth, this.$el.clientHeight);
39
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
+
40
81
  const options = {
41
82
  width: this.size,
42
83
  height: this.size,
@@ -48,10 +89,14 @@ export default {
48
89
  crossOrigin: 'anonymous',
49
90
  },
50
91
  data: this.content,
51
- ...this.qrCodeConfig,
92
+ ...rest,
52
93
  type: 'svg',
53
94
  };
54
95
 
96
+ if (!this.percentageMargin) {
97
+ this.isRendered = true;
98
+ }
99
+
55
100
  this.qrCode = new QRCodeStyling(options);
56
101
 
57
102
  this.onSizeChange = () => {
@@ -78,6 +123,10 @@ export default {
78
123
  options.height = qrSize;
79
124
 
80
125
  this.qrCode.update(options);
126
+
127
+ this.$nextTick(() => {
128
+ this.setScale();
129
+ });
81
130
  };
82
131
 
83
132
  this.resizeObserver = new ResizeObserver(this.onSizeChange);
@@ -87,6 +136,15 @@ export default {
87
136
  this.qrCode.append(this.$refs.qrCode);
88
137
 
89
138
  this.onSizeChange();
139
+
140
+ if (this.percentageMargin) {
141
+ this.qrCode.getRawData('svg').then(() => {
142
+ // QR code has been rendered
143
+ this.$nextTick(() => {
144
+ this.setScale();
145
+ });
146
+ });
147
+ }
90
148
  });
91
149
  },
92
150
  beforeUnmount() {
@@ -100,6 +158,11 @@ export default {
100
158
  svg {
101
159
  width: 100%;
102
160
  height: 100%;
161
+
162
+ rect,
163
+ image {
164
+ transform-origin: center;
165
+ }
103
166
  }
104
167
  }
105
168
  </style>
@@ -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.29",
3
+ "version": "4.1.31",
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 };