use-scroll-animate 5.4.0 → 5.6.0

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.
@@ -829,6 +829,618 @@ function defineStory(tag = 'usa-story') {
829
829
  }, { id: 'usa-story', text: CSS });
830
830
  }
831
831
 
832
+ /** Deterministic smooth pseudo-noise in [-1, 1] (sum of sines — cheap, no tables). */
833
+ function noise2(x, y, t = 0) {
834
+ return (Math.sin(x * 1.7 + t) * Math.cos(y * 1.3 - t * 0.7) + Math.sin((x + y) * 0.9 + t * 0.5) * 0.5 + Math.cos(x * 0.6 - y * 1.1 - t * 0.3) * 0.5) / 2;
835
+ }
836
+ /** Hex `#rrggbb` → [r, g, b]. */
837
+ function hexRgb(hex) {
838
+ const m = /^#?([\da-f]{2})([\da-f]{2})([\da-f]{2})$/i.exec(hex.trim());
839
+ return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [124, 92, 255];
840
+ }
841
+ /**
842
+ * Mount a generative canvas behind `el` and run `spec` on it. Returns the
843
+ * cleanup. Exposed for custom generative effects.
844
+ */
845
+ function canvasBackground(el, fx, spec, o) {
846
+ const canvas = document.createElement('canvas');
847
+ canvas.setAttribute('aria-hidden', 'true');
848
+ canvas.setAttribute('data-usa-fx-canvas', '');
849
+ canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;pointer-events:none;z-index:-1;border-radius:inherit';
850
+ const restore = [];
851
+ const set = (k, v) => {
852
+ restore.push([k, el.style[k]]);
853
+ el.style[k] = v;
854
+ };
855
+ if (getComputedStyle(el).position === 'static')
856
+ set('position', 'relative');
857
+ set('isolation', 'isolate');
858
+ el.prepend(canvas);
859
+ const ctx = canvas.getContext('2d');
860
+ let raf = 0;
861
+ let visible = true;
862
+ let quality = Math.min(1, Math.max(0.35, Number(o.quality) || 1));
863
+ let w = 0;
864
+ let h = 0;
865
+ let state = null;
866
+ const t0 = performance.now();
867
+ let last = t0;
868
+ let slow = 0;
869
+ let fast = 0;
870
+ const resize = () => {
871
+ const r = el.getBoundingClientRect();
872
+ w = Math.max(1, Math.round(r.width));
873
+ h = Math.max(1, Math.round(r.height));
874
+ const dpr = Math.min(2, (typeof devicePixelRatio === 'number' ? devicePixelRatio : 1) || 1) * quality;
875
+ canvas.width = Math.max(1, Math.round(w * dpr));
876
+ canvas.height = Math.max(1, Math.round(h * dpr));
877
+ if (ctx)
878
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
879
+ state = spec.init ? spec.init(w, h, o) : {};
880
+ };
881
+ const frame = (now) => {
882
+ raf = 0;
883
+ if (!ctx)
884
+ return;
885
+ const dt = now - last;
886
+ last = now;
887
+ // adaptive quality: sustained slow frames lower the render scale, fast ones raise it
888
+ if (dt > 28)
889
+ slow++;
890
+ else if (dt < 18)
891
+ fast++;
892
+ if (slow > 20 && quality > 0.35) {
893
+ quality = Math.max(0.35, quality - 0.15);
894
+ slow = fast = 0;
895
+ resize();
896
+ }
897
+ else if (fast > 240 && quality < 1) {
898
+ quality = Math.min(1, quality + 0.15);
899
+ slow = fast = 0;
900
+ resize();
901
+ }
902
+ spec.draw({ ctx, w, h, t: (now - t0) / 1000, quality, state, o });
903
+ if (visible && !document.hidden)
904
+ raf = requestAnimationFrame(frame);
905
+ };
906
+ const start = () => {
907
+ if (!raf && ctx && !fx.reduced) {
908
+ last = performance.now();
909
+ raf = requestAnimationFrame(frame);
910
+ }
911
+ };
912
+ resize();
913
+ if (ctx)
914
+ spec.draw({ ctx, w, h, t: 0, quality, state, o }); // first / static frame
915
+ const io = typeof IntersectionObserver === 'function' ? new IntersectionObserver((es) => {
916
+ visible = es.some((e) => e.isIntersecting);
917
+ if (visible)
918
+ start();
919
+ }) : null;
920
+ io?.observe(el);
921
+ const ro = typeof ResizeObserver === 'function' ? new ResizeObserver(() => {
922
+ resize();
923
+ if (ctx && fx.reduced)
924
+ spec.draw({ ctx, w, h, t: 0, quality, state, o });
925
+ }) : null;
926
+ ro?.observe(el);
927
+ const vis = () => !document.hidden && visible && start();
928
+ document.addEventListener('visibilitychange', vis);
929
+ start();
930
+ return () => {
931
+ cancelAnimationFrame(raf);
932
+ raf = 0;
933
+ io?.disconnect();
934
+ ro?.disconnect();
935
+ document.removeEventListener('visibilitychange', vis);
936
+ canvas.remove();
937
+ for (const [k, v] of restore)
938
+ el.style[k] = v;
939
+ };
940
+ }
941
+ const gen = (name, description, defaults, spec) => ({
942
+ name,
943
+ kind: 'background',
944
+ description,
945
+ reduced: 'run',
946
+ defaults: { colors: PALETTE, background: '#0b0d12', speed: 1, quality: 1, ...defaults },
947
+ run: (el, o, ctx) => canvasBackground(el, ctx, spec, o),
948
+ });
949
+ /** Low-resolution scalar field → ImageData, drawn scaled up (used by voronoi / metaballs / contours). */
950
+ function field(f, cell, px) {
951
+ const step = Math.max(2, Math.round(cell / f.quality));
952
+ const cw = Math.max(1, Math.ceil(f.w / step));
953
+ const ch = Math.max(1, Math.ceil(f.h / step));
954
+ const st = f.state;
955
+ if (!st.buf || st.buf.width !== cw || st.buf.height !== ch) {
956
+ st.buf = document.createElement('canvas');
957
+ st.buf.width = cw;
958
+ st.buf.height = ch;
959
+ st.bctx = st.buf.getContext('2d');
960
+ st.img = st.bctx?.createImageData(cw, ch);
961
+ }
962
+ if (!st.bctx || !st.img)
963
+ return;
964
+ const d = st.img.data;
965
+ for (let y = 0; y < ch; y++)
966
+ for (let x = 0; x < cw; x++)
967
+ px(x * step, y * step, d, (y * cw + x) * 4);
968
+ st.bctx.putImageData(st.img, 0, 0);
969
+ f.ctx.imageSmoothingEnabled = true;
970
+ f.ctx.drawImage(st.buf, 0, 0, f.w, f.h);
971
+ }
972
+ const GENERATIVE_FX = [
973
+ gen('flow-field', 'Particles drift along a slowly changing flow field, leaving silky trails.', { count: 600, fade: 0.06 }, {
974
+ init: (w, h, o) => ({ p: Array.from({ length: o.count }, () => [Math.random() * w, Math.random() * h]) }),
975
+ draw: ({ ctx, w, h, t, state, o, quality }) => {
976
+ ctx.fillStyle = o.background;
977
+ ctx.globalAlpha = t === 0 ? 1 : o.fade;
978
+ ctx.fillRect(0, 0, w, h);
979
+ ctx.globalAlpha = 0.8;
980
+ const n = Math.round(state.p.length * quality);
981
+ const steps = t === 0 ? 40 : 1;
982
+ for (let i = 0; i < n; i++) {
983
+ const q = state.p[i];
984
+ ctx.fillStyle = o.colors[i % o.colors.length];
985
+ for (let s = 0; s < steps; s++) {
986
+ const a = noise2(q[0] * 0.004, q[1] * 0.004, t * 0.15 * o.speed) * Math.PI * 2;
987
+ q[0] += Math.cos(a) * 1.2 * o.speed;
988
+ q[1] += Math.sin(a) * 1.2 * o.speed;
989
+ if (q[0] < 0 || q[0] > w || q[1] < 0 || q[1] > h)
990
+ (q[0] = Math.random() * w), (q[1] = Math.random() * h);
991
+ ctx.fillRect(q[0], q[1], 1.4, 1.4);
992
+ }
993
+ }
994
+ ctx.globalAlpha = 1;
995
+ },
996
+ }),
997
+ gen('voronoi', 'Drifting Voronoi cells in the palette, with darker borders.', { seeds: 14, cell: 6 }, {
998
+ init: (w, h, o) => ({ s: Array.from({ length: o.seeds }, (_, i) => ({ x: Math.random() * w, y: Math.random() * h, vx: Math.random() - 0.5, vy: Math.random() - 0.5, c: hexRgb(o.colors[i % o.colors.length]) })) }),
999
+ draw: (f) => {
1000
+ const { w, h, state, o } = f;
1001
+ for (const s of state.s) {
1002
+ s.x += s.vx * o.speed;
1003
+ s.y += s.vy * o.speed;
1004
+ if (s.x < 0 || s.x > w)
1005
+ s.vx *= -1;
1006
+ if (s.y < 0 || s.y > h)
1007
+ s.vy *= -1;
1008
+ }
1009
+ field(f, o.cell, (x, y, d, i) => {
1010
+ let b = Infinity;
1011
+ let b2 = Infinity;
1012
+ let c = state.s[0].c;
1013
+ for (const s of state.s) {
1014
+ const dd = (s.x - x) ** 2 + (s.y - y) ** 2;
1015
+ if (dd < b)
1016
+ (b2 = b), (b = dd), (c = s.c);
1017
+ else if (dd < b2)
1018
+ b2 = dd;
1019
+ }
1020
+ const edge = Math.sqrt(b2) - Math.sqrt(b) < 6 ? 0.55 : 1;
1021
+ d[i] = c[0] * edge;
1022
+ d[i + 1] = c[1] * edge;
1023
+ d[i + 2] = c[2] * edge;
1024
+ d[i + 3] = 255;
1025
+ });
1026
+ },
1027
+ }),
1028
+ gen('mesh-gradient', 'Soft blobs of colour orbit and blend into a living mesh gradient.', { blobs: 4, blur: 0.65 }, {
1029
+ draw: ({ ctx, w, h, t, o }) => {
1030
+ ctx.fillStyle = o.background;
1031
+ ctx.fillRect(0, 0, w, h);
1032
+ const r = Math.max(w, h) * o.blur;
1033
+ for (let i = 0; i < o.blobs; i++) {
1034
+ const a = t * 0.25 * o.speed + (i * Math.PI * 2) / o.blobs;
1035
+ const x = w / 2 + Math.cos(a * (1 + i * 0.13)) * w * 0.32;
1036
+ const y = h / 2 + Math.sin(a * (0.8 + i * 0.11)) * h * 0.32;
1037
+ const [cr, cg, cb] = hexRgb(o.colors[i % o.colors.length]);
1038
+ const g = ctx.createRadialGradient(x, y, 0, x, y, r);
1039
+ g.addColorStop(0, `rgba(${cr},${cg},${cb},0.7)`);
1040
+ g.addColorStop(1, `rgba(${cr},${cg},${cb},0)`);
1041
+ ctx.fillStyle = g;
1042
+ ctx.fillRect(0, 0, w, h);
1043
+ }
1044
+ },
1045
+ }),
1046
+ gen('starfield', 'Stars stream toward the viewer (warp speed with `speed: 4`).', { stars: 400, color: '#ffffff' }, {
1047
+ init: (_w, _h, o) => ({ s: Array.from({ length: o.stars }, () => [Math.random() * 2 - 1, Math.random() * 2 - 1, Math.random()]) }),
1048
+ draw: ({ ctx, w, h, t, state, o, quality }) => {
1049
+ ctx.fillStyle = o.background;
1050
+ ctx.fillRect(0, 0, w, h);
1051
+ ctx.fillStyle = o.color;
1052
+ const n = Math.round(state.s.length * quality);
1053
+ for (let i = 0; i < n; i++) {
1054
+ const s = state.s[i];
1055
+ if (t > 0)
1056
+ s[2] -= 0.004 * o.speed;
1057
+ if (s[2] <= 0.01)
1058
+ (s[0] = Math.random() * 2 - 1), (s[1] = Math.random() * 2 - 1), (s[2] = 1);
1059
+ const x = w / 2 + (s[0] / s[2]) * w * 0.5;
1060
+ const y = h / 2 + (s[1] / s[2]) * h * 0.5;
1061
+ if (x < 0 || x > w || y < 0 || y > h)
1062
+ continue;
1063
+ const size = (1 - s[2]) * 2.6;
1064
+ ctx.globalAlpha = Math.min(1, (1 - s[2]) * 1.6);
1065
+ ctx.fillRect(x, y, size, size);
1066
+ }
1067
+ ctx.globalAlpha = 1;
1068
+ },
1069
+ }),
1070
+ gen('metaballs', 'Gooey blobs that merge and split (low-resolution field, scaled up).', { balls: 6, cell: 5, threshold: 1 }, {
1071
+ init: (w, h, o) => ({ b: Array.from({ length: o.balls }, () => ({ x: Math.random() * w, y: Math.random() * h, vx: (Math.random() - 0.5) * 1.6, vy: (Math.random() - 0.5) * 1.6, r: Math.min(w, h) * (0.08 + Math.random() * 0.08) })) }),
1072
+ draw: (f) => {
1073
+ const { w, h, state, o } = f;
1074
+ for (const b of state.b) {
1075
+ b.x += b.vx * o.speed;
1076
+ b.y += b.vy * o.speed;
1077
+ if (b.x < 0 || b.x > w)
1078
+ b.vx *= -1;
1079
+ if (b.y < 0 || b.y > h)
1080
+ b.vy *= -1;
1081
+ }
1082
+ const [ar, ag, ab] = hexRgb(o.colors[0]);
1083
+ const [br, bg, bb] = hexRgb(o.colors[1] || o.colors[0]);
1084
+ const [kr, kg, kb] = hexRgb(o.background);
1085
+ field(f, o.cell, (x, y, d, i) => {
1086
+ let v = 0;
1087
+ for (const b of state.b)
1088
+ v += (b.r * b.r) / ((b.x - x) ** 2 + (b.y - y) ** 2 + 1);
1089
+ const inside = v >= o.threshold;
1090
+ const k = Math.min(1, (v - o.threshold) * 0.8);
1091
+ d[i] = inside ? ar + (br - ar) * k : kr;
1092
+ d[i + 1] = inside ? ag + (bg - ag) * k : kg;
1093
+ d[i + 2] = inside ? ab + (bb - ab) * k : kb;
1094
+ d[i + 3] = 255;
1095
+ });
1096
+ },
1097
+ }),
1098
+ gen('contours', 'Topographic contour lines over a slowly shifting height field.', { levels: 9, cell: 4, color: '#7c5cff' }, {
1099
+ draw: (f) => {
1100
+ const { t, o } = f;
1101
+ const [lr, lg, lb] = hexRgb(o.color);
1102
+ const [kr, kg, kb] = hexRgb(o.background);
1103
+ const z = (x, y) => (noise2(x * 0.006, y * 0.006, t * 0.2 * o.speed) + 1) / 2;
1104
+ const step = Math.max(2, Math.round(o.cell / f.quality));
1105
+ field(f, o.cell, (x, y, d, i) => {
1106
+ const a = Math.floor(z(x, y) * o.levels);
1107
+ const line = a !== Math.floor(z(x + step, y) * o.levels) || a !== Math.floor(z(x, y + step) * o.levels);
1108
+ const shade = 0.12 + (a / o.levels) * 0.18;
1109
+ d[i] = line ? lr : kr + (lr - kr) * shade;
1110
+ d[i + 1] = line ? lg : kg + (lg - kg) * shade;
1111
+ d[i + 2] = line ? lb : kb + (lb - kb) * shade;
1112
+ d[i + 3] = 255;
1113
+ });
1114
+ },
1115
+ }),
1116
+ ];
1117
+
1118
+ let actx = null;
1119
+ let current = null;
1120
+ const mediaSources = new WeakMap();
1121
+ /** The running analyser, if `enableAudio()` was called. */
1122
+ const getAudio = () => current;
1123
+ function audioContext() {
1124
+ const AC = globalThis.AudioContext || globalThis.webkitAudioContext;
1125
+ if (!AC)
1126
+ throw new Error('[use-scroll-animate] Web Audio is not available');
1127
+ if (!actx || actx.state === 'closed')
1128
+ actx = new AC();
1129
+ return actx;
1130
+ }
1131
+ /**
1132
+ * Start analysing `input` and make it the source of every sound-reactive
1133
+ * effect. Call from a user gesture. Replaces a previous source.
1134
+ */
1135
+ async function enableAudio(input = 'mic', opts = {}) {
1136
+ current?.stop();
1137
+ const ac = audioContext();
1138
+ if (ac.state === 'suspended')
1139
+ await ac.resume().catch(() => undefined);
1140
+ const analyser = ac.createAnalyser();
1141
+ analyser.fftSize = opts.fftSize || 512;
1142
+ analyser.smoothingTimeConstant = opts.smoothing ?? 0.8;
1143
+ let src;
1144
+ let release = () => { };
1145
+ const media = typeof input === 'string' && input !== 'mic' ? document.querySelector(input) : input;
1146
+ if (media === 'mic') {
1147
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1148
+ src = ac.createMediaStreamSource(stream);
1149
+ src.connect(analyser); // never to the speakers: no feedback
1150
+ release = () => stream.getTracks().forEach((t) => t.stop());
1151
+ }
1152
+ else if (media && typeof media.play === 'function') {
1153
+ const m = media;
1154
+ let node = mediaSources.get(m);
1155
+ if (!node)
1156
+ mediaSources.set(m, (node = ac.createMediaElementSource(m)));
1157
+ try {
1158
+ node.disconnect();
1159
+ }
1160
+ catch {
1161
+ /* not connected */
1162
+ }
1163
+ src = node;
1164
+ src.connect(analyser);
1165
+ analyser.connect(ac.destination);
1166
+ release = () => {
1167
+ try {
1168
+ node.disconnect();
1169
+ }
1170
+ catch {
1171
+ /* already */
1172
+ }
1173
+ node.connect(ac.destination); // keep the media audible
1174
+ };
1175
+ if (m.paused)
1176
+ await m.play()?.catch?.(() => undefined);
1177
+ }
1178
+ else if (media && typeof media.getTracks === 'function') {
1179
+ src = ac.createMediaStreamSource(media);
1180
+ src.connect(analyser);
1181
+ }
1182
+ else
1183
+ throw new Error(`[use-scroll-animate] enableAudio: no audio source for ${String(input)}`);
1184
+ const freq = new Uint8Array(analyser.frequencyBinCount);
1185
+ const wave = new Uint8Array(analyser.fftSize);
1186
+ const self = {
1187
+ context: ac,
1188
+ analyser,
1189
+ sample: () => {
1190
+ analyser.getByteFrequencyData(freq);
1191
+ analyser.getByteTimeDomainData(wave);
1192
+ let sq = 0;
1193
+ for (let i = 0; i < wave.length; i++)
1194
+ sq += ((wave[i] - 128) / 128) ** 2;
1195
+ const n = Math.max(1, Math.round(freq.length * 0.08));
1196
+ let b = 0;
1197
+ for (let i = 0; i < n; i++)
1198
+ b += freq[i];
1199
+ return { level: Math.min(1, Math.sqrt(sq / wave.length) * 2), bass: b / n / 255, freq, wave };
1200
+ },
1201
+ stop: () => {
1202
+ if (current === self) {
1203
+ current = null;
1204
+ if (raf && typeof cancelAnimationFrame === 'function')
1205
+ cancelAnimationFrame(raf);
1206
+ raf = 0;
1207
+ }
1208
+ try {
1209
+ src.disconnect();
1210
+ analyser.disconnect();
1211
+ }
1212
+ catch {
1213
+ /* already */
1214
+ }
1215
+ release();
1216
+ setVars(0, 0);
1217
+ },
1218
+ };
1219
+ current = self;
1220
+ kick();
1221
+ return self;
1222
+ }
1223
+ /** Stop the current audio source (if any). */
1224
+ function disableAudio() {
1225
+ current?.stop();
1226
+ }
1227
+ /** A pure beat detector: feed it energy (0–1) and a timestamp per frame; it answers "beat?". */
1228
+ function createBeatDetector(o = {}) {
1229
+ const { threshold = 1.35, cooldown = 250, history = 43, floor = 0.08 } = o;
1230
+ const hist = [];
1231
+ let last = -Infinity;
1232
+ return (energy, now) => {
1233
+ const avg = hist.length ? hist.reduce((a, b) => a + b, 0) / hist.length : energy;
1234
+ const ready = hist.length >= 8;
1235
+ hist.push(energy);
1236
+ if (hist.length > history)
1237
+ hist.shift();
1238
+ if (ready && energy > floor && energy > avg * threshold && now - last >= cooldown) {
1239
+ last = now;
1240
+ return true;
1241
+ }
1242
+ return false;
1243
+ };
1244
+ }
1245
+ const listeners = new Set();
1246
+ let raf = 0;
1247
+ function setVars(level, bass) {
1248
+ if (typeof document === 'undefined')
1249
+ return;
1250
+ const s = document.documentElement.style;
1251
+ s.setProperty('--usa-audio-level', level.toFixed(3));
1252
+ s.setProperty('--usa-audio-bass', bass.toFixed(3));
1253
+ }
1254
+ function loop(now) {
1255
+ raf = 0;
1256
+ if (!current)
1257
+ return;
1258
+ const { level, bass } = current.sample();
1259
+ const reduced = base.prefersReducedMotion();
1260
+ setVars(reduced ? 0 : level, reduced ? 0 : bass);
1261
+ for (const l of Array.from(listeners))
1262
+ if (l.detect(bass, now))
1263
+ l.cb({ energy: bass, time: now });
1264
+ kick();
1265
+ }
1266
+ function kick() {
1267
+ if (!raf && current && typeof requestAnimationFrame === 'function')
1268
+ raf = requestAnimationFrame(loop);
1269
+ }
1270
+ /** Call `cb` on every detected beat of the current audio source. Returns an unsubscribe. */
1271
+ function onBeat(cb, o = {}) {
1272
+ const l = { detect: createBeatDetector(o), cb };
1273
+ listeners.add(l);
1274
+ kick();
1275
+ return () => void listeners.delete(l);
1276
+ }
1277
+ /** Play the registered effect `name` on `el` at every beat (not under reduced motion). Returns an unbind. */
1278
+ function bindBeat(el, name, options = {}) {
1279
+ const { threshold, cooldown, history, floor, ...fx } = options;
1280
+ return onBeat(() => {
1281
+ if (!base.prefersReducedMotion())
1282
+ components_fx.playEffect(el, name, fx).catch(() => undefined);
1283
+ }, { threshold, cooldown, history, floor });
1284
+ }
1285
+ // --- visual effects ----------------------------------------------------------
1286
+ /** The current sample, or a gentle synthetic one while no audio is enabled. */
1287
+ function read(t, bins) {
1288
+ if (current)
1289
+ return { ...current.sample(), idle: false };
1290
+ const freq = new Uint8Array(bins);
1291
+ const wave = new Uint8Array(bins * 2);
1292
+ for (let i = 0; i < bins; i++)
1293
+ freq[i] = 70 + 50 * Math.sin(t * 2 + i * 0.35) * Math.cos(t * 0.7 + i * 0.11) * (1 - i / bins);
1294
+ for (let i = 0; i < wave.length; i++)
1295
+ wave[i] = 128 + 22 * Math.sin(t * 3 + i * 0.12);
1296
+ return { level: 0.25 + 0.1 * Math.sin(t * 2), bass: 0.35 + 0.2 * Math.sin(t * 2.4), freq, wave, idle: true };
1297
+ }
1298
+ const audioFx = (name, description, defaults, spec) => ({
1299
+ name,
1300
+ kind: 'background',
1301
+ description,
1302
+ reduced: 'skip',
1303
+ defaults: { colors: PALETTE, background: '#0b0d12', speed: 1, quality: 1, ...defaults },
1304
+ run: (el, o, ctx) => canvasBackground(el, ctx, spec, o),
1305
+ });
1306
+ const AUDIO_FX = [
1307
+ audioFx('spectrum-bars', 'Frequency bars dance to the audio (mirror them with `mirror: true`).', { bars: 48, gap: 2, mirror: false }, {
1308
+ draw: ({ ctx, w, h, t, o }) => {
1309
+ ctx.fillStyle = o.background;
1310
+ ctx.fillRect(0, 0, w, h);
1311
+ const s = read(t, o.bars);
1312
+ const per = Math.max(1, Math.floor((s.freq.length * 0.7) / o.bars));
1313
+ const bw = w / o.bars;
1314
+ for (let i = 0; i < o.bars; i++) {
1315
+ let v = 0;
1316
+ for (let k = 0; k < per; k++)
1317
+ v += s.freq[Math.min(s.freq.length - 1, i * per + k)];
1318
+ const bh = (v / per / 255) * h * (o.mirror ? 0.5 : 0.9);
1319
+ ctx.fillStyle = o.colors[i % o.colors.length];
1320
+ ctx.fillRect(i * bw + o.gap / 2, o.mirror ? h / 2 - bh : h - bh, Math.max(1, bw - o.gap), o.mirror ? bh * 2 : bh);
1321
+ }
1322
+ },
1323
+ }),
1324
+ audioFx('pulse-ring', 'Glowing rings pulse with the bass.', { rings: 3, color: '#7c5cff' }, {
1325
+ draw: ({ ctx, w, h, t, o }) => {
1326
+ ctx.fillStyle = o.background;
1327
+ ctx.fillRect(0, 0, w, h);
1328
+ const s = read(t, 32);
1329
+ const [r, g, b] = hexRgb(o.color);
1330
+ const base = Math.min(w, h) * 0.18;
1331
+ for (let i = 0; i < o.rings; i++) {
1332
+ const rad = base * (1 + i * 0.55) * (1 + s.bass * 0.6);
1333
+ ctx.strokeStyle = `rgba(${r},${g},${b},${(0.9 - i * 0.25) * (0.4 + s.level)})`;
1334
+ ctx.lineWidth = 3 + s.bass * 10 - i;
1335
+ ctx.beginPath();
1336
+ ctx.arc(w / 2, h / 2, rad, 0, Math.PI * 2);
1337
+ ctx.stroke();
1338
+ }
1339
+ },
1340
+ }),
1341
+ audioFx('wave-ring', 'The live waveform wrapped into a circle.', { color: '#22d3ee', amplitude: 0.35 }, {
1342
+ draw: ({ ctx, w, h, t, o }) => {
1343
+ ctx.fillStyle = o.background;
1344
+ ctx.fillRect(0, 0, w, h);
1345
+ const s = read(t, 64);
1346
+ const n = s.wave.length;
1347
+ const base = Math.min(w, h) * 0.28;
1348
+ ctx.strokeStyle = o.color;
1349
+ ctx.lineWidth = 2;
1350
+ ctx.beginPath();
1351
+ for (let i = 0; i <= n; i++) {
1352
+ const a = (i / n) * Math.PI * 2;
1353
+ const rad = base * (1 + ((s.wave[i % n] - 128) / 128) * o.amplitude * 2);
1354
+ const x = w / 2 + Math.cos(a) * rad;
1355
+ const y = h / 2 + Math.sin(a) * rad;
1356
+ if (i)
1357
+ ctx.lineTo(x, y);
1358
+ else
1359
+ ctx.moveTo(x, y);
1360
+ }
1361
+ ctx.closePath();
1362
+ ctx.stroke();
1363
+ },
1364
+ }),
1365
+ ];
1366
+ /**
1367
+ * `<usa-audio source="#track | mic" label="…">` — a toggle button (yours, as
1368
+ * `[data-audio-toggle]`, or one it renders) that enables the audio source on
1369
+ * click; children with `data-usa-beat="effect"` play that effect on every
1370
+ * beat (`data-usa-beat-options` JSON; `threshold` / `cooldown` attributes).
1371
+ * Emits `usa-beat` and `usa-audio-error`.
1372
+ */
1373
+ function defineAudio(tag = 'usa-audio') {
1374
+ return base.defineElement(tag, (Base) => class UsaAudio extends Base {
1375
+ constructor() {
1376
+ super(...arguments);
1377
+ this.active = false;
1378
+ this.audio = null;
1379
+ this.off = null;
1380
+ }
1381
+ static get observedAttributes() {
1382
+ return ['source'];
1383
+ }
1384
+ button() {
1385
+ let b = this.querySelector('[data-audio-toggle]');
1386
+ if (!b) {
1387
+ b = document.createElement('button');
1388
+ b.type = 'button';
1389
+ b.setAttribute('data-audio-toggle', '');
1390
+ b.textContent = this.str('label', '🔊 Enable sound-reactive effects');
1391
+ this.prepend(b);
1392
+ this.onCleanup(() => b.remove());
1393
+ }
1394
+ return b;
1395
+ }
1396
+ async toggle() {
1397
+ const b = this.button();
1398
+ if (this.active)
1399
+ return this.stop();
1400
+ try {
1401
+ this.audio = await enableAudio(this.str('source', 'mic'));
1402
+ this.active = true;
1403
+ this.removeAttribute('data-audio-error');
1404
+ b.setAttribute('aria-pressed', 'true');
1405
+ this.off = onBeat((d) => {
1406
+ this.dispatchEvent(new CustomEvent('usa-beat', { detail: d, bubbles: true }));
1407
+ if (base.prefersReducedMotion())
1408
+ return;
1409
+ this.querySelectorAll('[data-usa-beat]').forEach((el) => {
1410
+ let opts = {};
1411
+ try {
1412
+ opts = JSON.parse(el.dataset.usaBeatOptions || '{}') || {};
1413
+ }
1414
+ catch {
1415
+ /* ignore bad JSON */
1416
+ }
1417
+ components_fx.playEffect(el, el.dataset.usaBeat || 'pulse', opts).catch(() => undefined);
1418
+ });
1419
+ }, { threshold: this.num('threshold', 1.35), cooldown: this.num('cooldown', 250) });
1420
+ }
1421
+ catch (err) {
1422
+ this.setAttribute('data-audio-error', '');
1423
+ this.dispatchEvent(new CustomEvent('usa-audio-error', { detail: { error: err }, bubbles: true }));
1424
+ }
1425
+ }
1426
+ stop() {
1427
+ this.off?.();
1428
+ this.off = null;
1429
+ if (this.audio && current === this.audio)
1430
+ this.audio.stop();
1431
+ this.audio = null;
1432
+ this.active = false;
1433
+ this.querySelector('[data-audio-toggle]')?.setAttribute('aria-pressed', 'false');
1434
+ }
1435
+ mount() {
1436
+ const b = this.button();
1437
+ b.setAttribute('aria-pressed', 'false');
1438
+ this.listen(b, 'click', () => void this.toggle());
1439
+ this.onCleanup(() => this.stop());
1440
+ }
1441
+ }, { id: 'usa-audio', text: 'usa-audio{display:block}usa-audio[data-audio-error] [data-audio-toggle]{outline:2px solid #ff5c8a}' });
1442
+ }
1443
+
832
1444
  /**
833
1445
  * use-scroll-animate/components/effects — the 5.x effect packs, all
834
1446
  * registered through `registerEffect()` (5.0) and playable with
@@ -846,6 +1458,8 @@ const EFFECT_PACKS = {
846
1458
  'cards-click': [...CARD_FX, ...CLICK_FX],
847
1459
  physics: PHYSICS_FX,
848
1460
  page: PAGE_FX,
1461
+ generative: GENERATIVE_FX,
1462
+ audio: AUDIO_FX,
849
1463
  };
850
1464
  /** 5.1: card & click effects 2.0. */
851
1465
  function registerCardClickEffects() {
@@ -859,9 +1473,18 @@ function registerPhysicsEffects() {
859
1473
  function registerPageEffects() {
860
1474
  components_fx.registerEffects(EFFECT_PACKS.page);
861
1475
  }
1476
+ /** 5.5: generative Canvas 2D backgrounds. */
1477
+ function registerGenerativeEffects() {
1478
+ components_fx.registerEffects(EFFECT_PACKS.generative);
1479
+ }
1480
+ /** 5.6: sound-reactive (Web Audio) backgrounds. */
1481
+ function registerAudioEffects() {
1482
+ components_fx.registerEffects(EFFECT_PACKS.audio);
1483
+ }
862
1484
  /** Define the 5.x elements of this entry (`<usa-story>`, …) under their default tags. */
863
1485
  function defineEffectElements() {
864
1486
  defineStory();
1487
+ defineAudio();
865
1488
  }
866
1489
  /** Register the built-ins and every pack (idempotent). */
867
1490
  function registerAllEffects() {
@@ -870,19 +1493,33 @@ function registerAllEffects() {
870
1493
  components_fx.registerEffects(defs);
871
1494
  }
872
1495
 
1496
+ exports.AUDIO_FX = AUDIO_FX;
873
1497
  exports.CARD_FX = CARD_FX;
874
1498
  exports.CLICK_FX = CLICK_FX;
875
1499
  exports.EFFECT_PACKS = EFFECT_PACKS;
1500
+ exports.GENERATIVE_FX = GENERATIVE_FX;
876
1501
  exports.PAGE_FX = PAGE_FX;
877
1502
  exports.PHYSICS_FX = PHYSICS_FX;
878
1503
  exports.STORY_TEMPLATES = STORY_TEMPLATES;
1504
+ exports.bindBeat = bindBeat;
879
1505
  exports.bounceKeyframes = bounceKeyframes;
1506
+ exports.canvasBackground = canvasBackground;
1507
+ exports.createBeatDetector = createBeatDetector;
1508
+ exports.defineAudio = defineAudio;
880
1509
  exports.defineEffectElements = defineEffectElements;
881
1510
  exports.defineStory = defineStory;
1511
+ exports.disableAudio = disableAudio;
1512
+ exports.enableAudio = enableAudio;
882
1513
  exports.formatCount = formatCount;
883
1514
  exports.fxLayer = fxLayer;
1515
+ exports.getAudio = getAudio;
1516
+ exports.hexRgb = hexRgb;
1517
+ exports.noise2 = noise2;
1518
+ exports.onBeat = onBeat;
884
1519
  exports.registerAllEffects = registerAllEffects;
1520
+ exports.registerAudioEffects = registerAudioEffects;
885
1521
  exports.registerCardClickEffects = registerCardClickEffects;
1522
+ exports.registerGenerativeEffects = registerGenerativeEffects;
886
1523
  exports.registerPageEffects = registerPageEffects;
887
1524
  exports.registerPhysicsEffects = registerPhysicsEffects;
888
1525
  exports.solveSpring = solveSpring;