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