distortion-wave 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +204 -2
- package/dist/distortion-wave.iife.min.js +159 -0
- package/dist/distortion-wave.js +1409 -0
- package/dist/distortion-wave.min.js +159 -0
- package/package.json +61 -4
- package/src/capture.js +295 -0
- package/src/core.js +169 -0
- package/src/focus.js +34 -0
- package/src/index.js +281 -0
- package/src/renderer.js +295 -0
- package/types/index.d.ts +81 -0
package/src/core.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// core.js — pure, DOM-free: config, scaling, timeline, particles (unit-tested in Node)
|
|
2
|
+
// Part of distortion-wave · MIT © Lucas Bonomi
|
|
3
|
+
|
|
4
|
+
// ═══ Math ════════════════════════════════════════════════════════════════════
|
|
5
|
+
export const TAU = Math.PI * 2;
|
|
6
|
+
export const clamp = (x, a = 0, b = 1) => Math.min(Math.max(x, a), b);
|
|
7
|
+
export const lerp = (a, b, t) => a + (b - a) * t;
|
|
8
|
+
export const fract = (x) => x - Math.floor(x);
|
|
9
|
+
export const smooth = (e0, e1, x) => { const t = clamp((x - e0) / ((e1 - e0) || 1e-6)); return t * t * (3 - 2 * t); };
|
|
10
|
+
export function cubicBezier(x1, y1, x2, y2) {
|
|
11
|
+
const b = (t, p1, p2) => { const u = 1 - t; return 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t; };
|
|
12
|
+
return (x) => {
|
|
13
|
+
if (x <= 0) return 0; if (x >= 1) return 1;
|
|
14
|
+
let lo = 0, hi = 1;
|
|
15
|
+
for (let i = 0; i < 22; i++) { const m = (lo + hi) / 2; if (b(m, x1, x2) < x) lo = m; else hi = m; }
|
|
16
|
+
return b((lo + hi) / 2, y1, y2);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const easeLaunch = cubicBezier(0.2, 1, 0.3, 1);
|
|
20
|
+
const easeClear = cubicBezier(0.45, 0, 0.55, 1);
|
|
21
|
+
export function hexToOklab(hex) {
|
|
22
|
+
const n = parseInt(hex.replace('#', ''), 16);
|
|
23
|
+
const [r, g, b] = [n >> 16 & 255, n >> 8 & 255, n & 255].map((v) => { v /= 255; return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4; });
|
|
24
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
25
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
26
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
27
|
+
return [0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s, 1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ═══ Config ══════════════════════════════════════════════════════════════════
|
|
31
|
+
export const PALETTES = {
|
|
32
|
+
aurora: ['#FF4FD8', '#A35BFF', '#5B7CFF', '#38D9FF', '#FFD36B', '#FF7A5C'],
|
|
33
|
+
ocean: ['#3A7BFF', '#2EE6D6', '#7C5CFF', '#1FB6FF', '#B8F2FF'],
|
|
34
|
+
mint: ['#2BD67B', '#B6FF5C', '#1FC8A9', '#E8FFB0', '#3CE0C0'],
|
|
35
|
+
ember: ['#FF5A3C', '#FFB23C', '#FF3C8E', '#FFE07A', '#FF7F50'],
|
|
36
|
+
};
|
|
37
|
+
// Reference values for a ~360×740 px screen (k = 1). scaleConfig() adapts them to the target.
|
|
38
|
+
export const BASE = {
|
|
39
|
+
type: 'vertical', speed: 1,
|
|
40
|
+
anticipation: 0.45, travel: 2.2, linger: -0.3, rimFade: 2.8, hairDelay: 0.45, hairFade: 2.8,
|
|
41
|
+
pull: 22, inhale: 50, gatherGlow: 0.35, gatherDots: 1,
|
|
42
|
+
amplitude: 32, lensWidth: 140, aberration: 0.75, swell: 20,
|
|
43
|
+
blur: 18, blurNear: 6, blurAhead: 90, blurBehind: 230,
|
|
44
|
+
lead: 120, tail: 280, band: 0.9,
|
|
45
|
+
rimWidth: 14, rimSoftness: 320, rimStrength: 0.85, uneven: 1, hairWidth: 1.5,
|
|
46
|
+
lean: 0.9, margin: 60, seam: 48,
|
|
47
|
+
particles: 64, particleSize: 1.8, particleLife: 2.1, reach: 140, spread: 1,
|
|
48
|
+
palette: 'aurora',
|
|
49
|
+
};
|
|
50
|
+
export const PRESETS = {
|
|
51
|
+
vertical: BASE,
|
|
52
|
+
horizontal: { ...BASE, type: 'horizontal' },
|
|
53
|
+
center: { ...BASE, type: 'center', anticipation: 0.25, travel: 3.2, linger: -0.2, rimFade: 2.4, hairDelay: 0.9,
|
|
54
|
+
pull: 14, inhale: 32, gatherGlow: 0.2, gatherDots: 0, amplitude: 20, lensWidth: 90, aberration: 0.35, swell: 13,
|
|
55
|
+
blur: 11, blurAhead: 54, blurBehind: 140, lead: 80, tail: 180, band: 0.75, rimStrength: 0.7, rimSoftness: 200,
|
|
56
|
+
particles: 90, particleLife: 3.0, reach: 90, spread: 0.65 },
|
|
57
|
+
};
|
|
58
|
+
const PX_KEYS = ['pull', 'inhale', 'amplitude', 'lensWidth', 'swell', 'blur', 'blurNear', 'blurAhead', 'blurBehind',
|
|
59
|
+
'lead', 'tail', 'rimWidth', 'rimSoftness', 'reach', 'margin', 'seam'];
|
|
60
|
+
const T_KEYS = ['anticipation', 'travel', 'linger', 'rimFade', 'hairDelay', 'hairFade', 'particleLife'];
|
|
61
|
+
|
|
62
|
+
/** Scales px & seconds to the target size: a button is not a screen. Pure. */
|
|
63
|
+
export function scaleConfig(c, w, h) {
|
|
64
|
+
const k = clamp(Math.sqrt(w * h) / 516, 0.12, 1.8); // 516 ≈ √(360×740)
|
|
65
|
+
const ts = clamp(0.45 + 0.55 * Math.sqrt(k), 0.6, 1.15) / (c.speed || 1);
|
|
66
|
+
const o = { ...c, k };
|
|
67
|
+
for (const key of PX_KEYS) o[key] = c[key] * k;
|
|
68
|
+
for (const key of T_KEYS) o[key] = c[key] * ts;
|
|
69
|
+
o.blurNear = Math.max(o.blurNear, 1.2); o.blur = Math.max(o.blur, o.blurNear);
|
|
70
|
+
o.rimWidth = Math.max(o.rimWidth, 2.5); o.seam = Math.max(o.seam, 4);
|
|
71
|
+
o.anticipation = Math.max(o.anticipation, 0.2 / (c.speed || 1));
|
|
72
|
+
o.particles = Math.round(c.particles * clamp(k, 0.3, 1.5));
|
|
73
|
+
o.particleSize = c.particleSize * clamp(0.6 + 0.4 * k, 0.7, 1.3);
|
|
74
|
+
return o;
|
|
75
|
+
}
|
|
76
|
+
export const totalDuration = (c) => c.anticipation + Math.max(c.travel * 0.6, c.linger + c.rimFade, c.linger + c.hairDelay + c.hairFade, c.particleLife);
|
|
77
|
+
|
|
78
|
+
// ═══ Pure timeline: (t, config, geometry) → frame state ══════════════════════
|
|
79
|
+
function farthestCorner(ox, oy, tilt, squash, w, h) {
|
|
80
|
+
const c = Math.cos(tilt), s = Math.sin(tilt);
|
|
81
|
+
let best = 0;
|
|
82
|
+
for (const [x, y] of [[0, 0], [w, 0], [0, h], [w, h]]) {
|
|
83
|
+
const dx = x - ox, dy = y - oy;
|
|
84
|
+
best = Math.max(best, Math.hypot((c * dx + s * dy) * squash, -s * dx + c * dy));
|
|
85
|
+
}
|
|
86
|
+
return best;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* @param g { w, h, src:{x,y,w,h}, touch:{x,y}|null, hold:boolean } — CSS px, canvas space.
|
|
90
|
+
* The source may sit outside the target: the wave then enters from that edge.
|
|
91
|
+
*/
|
|
92
|
+
export function frameState(t, c, g) {
|
|
93
|
+
const ring = c.type === 'center', horiz = c.type === 'horizontal';
|
|
94
|
+
const T = totalDuration(c), time = clamp(t, 0, T);
|
|
95
|
+
const fly = time - c.anticipation, pre = smooth(0, c.anticipation, time);
|
|
96
|
+
const prog = clamp(fly / c.travel), e = easeLaunch(prog);
|
|
97
|
+
const s = g.src, cx = s.x + s.w / 2, cy = s.y + s.h / 2;
|
|
98
|
+
const r0 = Math.max(Math.min(s.w, s.h) / 2, 1);
|
|
99
|
+
const half = (horiz ? s.h : s.w) / 2, perp = (horiz ? s.w : s.h) / 2;
|
|
100
|
+
const base = horiz ? Math.PI / 2 : 0, ax = Math.cos(base), ay = Math.sin(base);
|
|
101
|
+
// "forward" = the side where most of the target lies (the wave travels inward)
|
|
102
|
+
const dir = (g.w / 2 - cx) * Math.sin(base) + (g.h / 2 - cy) * -Math.cos(base) >= 0 ? 1 : -1;
|
|
103
|
+
let lean = 0;
|
|
104
|
+
if (!ring && g.touch) lean = clamp(((g.touch.x - cx) * ax + (g.touch.y - cy) * ay) / Math.max(half, 1), -1, 1) * c.lean;
|
|
105
|
+
const settle = smooth(0, 0.45, e);
|
|
106
|
+
const ox = cx + ax * lean * half * settle, oy = cy + ay * lean * half * settle;
|
|
107
|
+
const tilt = base + dir * lean * 0.45 * settle;
|
|
108
|
+
const squashMax = ring ? 1 : 1.4;
|
|
109
|
+
const reach = farthestCorner(ox, oy, tilt, squashMax, g.w, g.h) + c.swell * squashMax + c.margin;
|
|
110
|
+
const radius = fly < 0 ? r0 + c.inhale * (1 - pre) : r0 + (reach + c.margin * 1.6 - r0) * e;
|
|
111
|
+
const fade = 1 - smooth(0.2, 0.55, prog);
|
|
112
|
+
const lift = c.gatherGlow * 0.5;
|
|
113
|
+
const energy = smooth(0, 0.05, prog) * fade;
|
|
114
|
+
const covered = fly > 0 && radius - c.swell > reach;
|
|
115
|
+
return {
|
|
116
|
+
time, fly, pre, prog, moved: e, ring, dir, r0, half, perp, cx, cy,
|
|
117
|
+
ox, oy, tilt, squash: lerp(1, squashMax, smooth(0, 0.5, e)),
|
|
118
|
+
spine: ring ? 0 : Math.max(0, half - r0) * (1 - settle),
|
|
119
|
+
lx: Math.cos(tilt), ly: Math.sin(tilt), fx: dir * Math.sin(tilt), fy: -dir * Math.cos(tilt),
|
|
120
|
+
radius, swell: c.swell * smooth(0, 0.3, e), energy,
|
|
121
|
+
pull: c.pull * pre * (1 - smooth(0, 0.22, fly)),
|
|
122
|
+
glow: fly < 0 ? lift * pre : lerp(lift, 1, smooth(0, 0.07, prog)) * fade,
|
|
123
|
+
clear: lerp(r0 - c.rimSoftness, reach + c.rimSoftness, easeClear((fly - c.linger) / c.rimFade)),
|
|
124
|
+
hairClear: lerp(r0 - c.rimSoftness, reach + c.rimSoftness, easeClear((fly - c.linger - c.hairDelay) / c.hairFade)),
|
|
125
|
+
rim: c.rimStrength * smooth(0, 0.1, prog) * (1 - smooth(0.9, 1, time / T)),
|
|
126
|
+
hold: ring && g.hold ? r0 : 0,
|
|
127
|
+
launched: fly > 0, covered,
|
|
128
|
+
settled: covered && prog >= 0.55, // no more lens or blur → the live DOM can take over
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ═══ Deterministic particles ═════════════════════════════════════════════════
|
|
133
|
+
export function makeParticles(n, c) {
|
|
134
|
+
return Array.from({ length: n }, (_, i) => {
|
|
135
|
+
const h = (k) => fract((i + 1) * k);
|
|
136
|
+
return {
|
|
137
|
+
col: h(0.6180339887) * 2 - 1, gy: h(0.7071067812), head: h(0.1618033989) * TAU,
|
|
138
|
+
pace: 0.4 + 0.65 * Math.sqrt(h(0.7548776662)), drift: 12 + 45 * h(0.5698402910),
|
|
139
|
+
sway: 3 + 10 * h(0.4142135624), swayHz: 2 + 3 * h(0.7320508076), tw: 7 + 10 * h(0.2360679775),
|
|
140
|
+
ph: h(0.3819660113) * TAU, hop: h(0.5497004779), life: c.particleLife * (0.45 + 0.55 * h(0.6457513111)),
|
|
141
|
+
size: c.particleSize * (0.55 + 0.45 * h(0.8774) ** 2), bright: 0.35 + 0.65 * h(0.6055512755),
|
|
142
|
+
};
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
export function particleAt(P, s, c) {
|
|
146
|
+
const tw = 0.75 + 0.25 * Math.sin(s.time * P.tw + P.ph), k = c.k;
|
|
147
|
+
if (!s.launched) {
|
|
148
|
+
if (!c.gatherDots) return null;
|
|
149
|
+
const cyc = s.time / 0.32 + P.hop, f = fract(cyc);
|
|
150
|
+
const a = smooth(0, 0.3, f) * (1 - smooth(0.6, 1, f)) * P.bright * tw
|
|
151
|
+
* smooth(0, c.anticipation * 0.35, s.time) * (1 - smooth(c.anticipation * 0.75, c.anticipation, s.time));
|
|
152
|
+
const fall = c.reach * (1 - f * f);
|
|
153
|
+
if (s.ring) { const ang = P.head + Math.floor(cyc) * 2.399963; const rr = s.r0 + 12 * k + fall; return { x: s.cx + Math.cos(ang) * rr, y: s.cy + Math.sin(ang) * rr, a }; }
|
|
154
|
+
const lat = P.col * (s.half + 16 * k) * (1 - 0.35 * (1 - f)), fwd = s.perp + 10 * k + fall * (0.45 + 0.55 * P.gy);
|
|
155
|
+
return { x: s.cx + s.lx * lat + s.fx * fwd, y: s.cy + s.ly * lat + s.fy * fwd, a };
|
|
156
|
+
}
|
|
157
|
+
const fly = s.fly;
|
|
158
|
+
const a = (1 - smooth(P.life * 0.55, P.life, fly)) * P.bright * tw * smooth(0, 0.08, fly);
|
|
159
|
+
const sway = P.sway * Math.max(k, 0.3) * Math.sin(P.swayHz * fly + P.ph) * Math.min(fly / 0.25, 1);
|
|
160
|
+
const travel = (s.radius - s.r0) * P.pace * c.spread;
|
|
161
|
+
if (s.ring) {
|
|
162
|
+
const rr = s.r0 + travel + P.drift * k * fly, co = Math.cos(P.head), si = Math.sin(P.head);
|
|
163
|
+
return { x: s.ox + co * rr - si * sway, y: s.oy + si * rr + co * sway, a };
|
|
164
|
+
}
|
|
165
|
+
const lat = P.col * s.half * (1 + 0.25 * s.moved * P.pace) + sway;
|
|
166
|
+
const fwd = (s.r0 + travel) * (1 - 0.3 * P.col * P.col) + P.drift * k * fly;
|
|
167
|
+
return { x: s.ox + s.lx * lat + s.fx * fwd, y: s.oy + s.ly * lat + s.fy * fwd, a };
|
|
168
|
+
}
|
|
169
|
+
|
package/src/focus.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// focus.js — keep keyboard focus on the equivalent control when an update replaces it
|
|
2
|
+
// Part of distortion-wave · MIT © Lucas Bonomi
|
|
3
|
+
|
|
4
|
+
// Keyboard focus
|
|
5
|
+
// The wave never touches focus (the target only goes opacity:0, so it stays focusable and
|
|
6
|
+
// in the accessibility tree). But if update() REPLACES the focused node (innerHTML, re-render),
|
|
7
|
+
// the browser drops focus to <body>. We then move it to the equivalent control:
|
|
8
|
+
// same id → same data-focus-key/name → same place in the tree → same rank among
|
|
9
|
+
// focusable elements → the target itself.
|
|
10
|
+
const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),summary,[contenteditable="true"],[tabindex]:not([tabindex="-1"])';
|
|
11
|
+
export function captureFocus(root) {
|
|
12
|
+
const el = document.activeElement;
|
|
13
|
+
if (!el || el === document.body || !root.contains(el)) return null;
|
|
14
|
+
const path = [];
|
|
15
|
+
for (let n = el; n && n !== root; n = n.parentElement) path.unshift([...n.parentElement.children].indexOf(n));
|
|
16
|
+
const sel = 'selectionStart' in el ? (() => { try { return [el.selectionStart, el.selectionEnd]; } catch { return null; } })() : null;
|
|
17
|
+
return { el, id: el.id, key: el.getAttribute('data-focus-key') || el.getAttribute('name'), tag: el.localName, path,
|
|
18
|
+
order: [...root.querySelectorAll(FOCUSABLE)].indexOf(el), sel };
|
|
19
|
+
}
|
|
20
|
+
export function restoreFocus(info, root) {
|
|
21
|
+
if (!info || info.el.isConnected) return; // node kept: focus never moved
|
|
22
|
+
const now = document.activeElement;
|
|
23
|
+
if (now && now !== document.body && now !== document.documentElement) return; // the app moved focus itself: leave it
|
|
24
|
+
let c = null;
|
|
25
|
+
if (info.id) c = root.querySelector('#' + CSS.escape(info.id));
|
|
26
|
+
if (!c && info.key) c = root.querySelector(`[data-focus-key="${CSS.escape(info.key)}"],[name="${CSS.escape(info.key)}"]`);
|
|
27
|
+
if (!c) { let n = root; for (const i of info.path) n = n?.children[i]; if (n && n !== root && n.localName === info.tag && n.matches(FOCUSABLE)) c = n; }
|
|
28
|
+
if (!c && info.order >= 0) { const f = root.querySelectorAll(FOCUSABLE); c = f[Math.min(info.order, f.length - 1)] || null; }
|
|
29
|
+
if (!c && root.matches(FOCUSABLE)) c = root;
|
|
30
|
+
if (!c) return;
|
|
31
|
+
c.focus({ preventScroll: true });
|
|
32
|
+
if (info.sel && typeof c.setSelectionRange === 'function') try { c.setSelectionRange(info.sel[0], info.sel[1]); } catch {}
|
|
33
|
+
}
|
|
34
|
+
|
package/src/index.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// distortion-wave — a distortion-wave transition for ANY DOM element (button, card, section, page).
|
|
2
|
+
// WebGL2, zero dependencies. MIT © Lucas Bonomi
|
|
3
|
+
//
|
|
4
|
+
// const t = distortionWave(target, () => { /* mutate the DOM */ }, {
|
|
5
|
+
// source: buttonEl | DOMRect | {x,y} | null, // where the wave starts
|
|
6
|
+
// focus: pointerEvent | {x,y} | null, // touch point (sets the lean)
|
|
7
|
+
// variant: 'auto' | 'vertical' | 'horizontal' | 'center',
|
|
8
|
+
// palette: 'aurora' | ['#hex', …],
|
|
9
|
+
// });
|
|
10
|
+
// await t.ready; await t.updateCallbackDone; await t.covered; await t.finished; t.skip();
|
|
11
|
+
//
|
|
12
|
+
// Same model as document.startViewTransition(): capture "before", update the DOM,
|
|
13
|
+
// capture "after", animate on top, then hand back to the real DOM (interactive as
|
|
14
|
+
// soon as the wave has passed).
|
|
15
|
+
|
|
16
|
+
import { snapshotElement } from './capture.js';
|
|
17
|
+
import { BASE, PRESETS, PALETTES, scaleConfig, totalDuration, frameState, makeParticles, particleAt, clamp, lerp } from './core.js';
|
|
18
|
+
import { acquire, release, warmupWave } from './renderer.js';
|
|
19
|
+
import { captureFocus, restoreFocus } from './focus.js';
|
|
20
|
+
|
|
21
|
+
// ═══ Measuring & DOM helpers ═════════════════════════════════════════════════
|
|
22
|
+
function parseRadii(cs, w, h) {
|
|
23
|
+
const one = (v, side) => { const t = String(v).trim().split(/\s+/)[0]; const n = parseFloat(t) || 0; return t.endsWith('%') ? n / 100 * side : n; };
|
|
24
|
+
let tl = one(cs.borderTopLeftRadius, w), tr = one(cs.borderTopRightRadius, w), br = one(cs.borderBottomRightRadius, w), bl = one(cs.borderBottomLeftRadius, w);
|
|
25
|
+
const f = Math.min(1, w / ((tl + tr) || 1e9), w / ((bl + br) || 1e9), h / ((tl + bl) || 1e9), h / ((tr + br) || 1e9)); // CSS overlapping-radii rule
|
|
26
|
+
return { tl: tl * f, tr: tr * f, br: br * f, bl: bl * f };
|
|
27
|
+
}
|
|
28
|
+
function isFixed(el) { for (let n = el; n && n !== document.documentElement; n = n.parentElement) if (getComputedStyle(n).position === 'fixed') return true; return false; }
|
|
29
|
+
function measure(el) {
|
|
30
|
+
const rect = el.getBoundingClientRect(), cs = getComputedStyle(el);
|
|
31
|
+
const w = el.offsetWidth ?? rect.width, h = el.offsetHeight ?? rect.height;
|
|
32
|
+
const x0 = Math.max(rect.left, 0), y0 = Math.max(rect.top, 0), x1 = Math.min(rect.right, innerWidth), y1 = Math.min(rect.bottom, innerHeight);
|
|
33
|
+
const fixed = isFixed(el), sx = fixed ? 0 : scrollX, sy = fixed ? 0 : scrollY;
|
|
34
|
+
return {
|
|
35
|
+
w, h, fixed, opacity: cs.opacity, radii: parseRadii(cs, w, h), visible: x1 > x0 && y1 > y0,
|
|
36
|
+
clip: { x: x0 - rect.left, y: y0 - rect.top, w: x1 - x0, h: y1 - y0 }, // visible part, element space
|
|
37
|
+
page: { x: x0 + sx, y: y0 + sy, w: x1 - x0, h: y1 - y0 }, // same, page space (scroll-stable)
|
|
38
|
+
el: { x: rect.left + sx, y: rect.top + sy, w: rect.width, h: rect.height },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const union = (a, b) => { const x = Math.min(a.x, b.x), y = Math.min(a.y, b.y); return { x, y, w: Math.max(a.x + a.w, b.x + b.w) - x, h: Math.max(a.y + a.h, b.y + b.h) - y }; };
|
|
42
|
+
const rel = (r, o) => ({ x: r.x - o.x, y: r.y - o.y, w: r.w, h: r.h });
|
|
43
|
+
const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r()));
|
|
44
|
+
function deferred() { let res, rej; const p = new Promise((a, b) => { res = a; rej = b; }); p.catch(() => {}); return { p, res, rej }; }
|
|
45
|
+
function pickDpr(m, maxTex) {
|
|
46
|
+
const area = Math.max(m.clip.w * m.clip.h, 1);
|
|
47
|
+
return Math.max(0.5, Math.min(window.devicePixelRatio || 1, 2, Math.sqrt(4.2e6 / area), maxTex / Math.max(m.clip.w, m.clip.h, 1)));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Optional pre-capture (on trigger hover/focus): hides the latency of the "before" capture.
|
|
51
|
+
const prepared = new WeakMap();
|
|
52
|
+
export function prepareWave(target) {
|
|
53
|
+
if (prepared.get(target)?.pending) return prepared.get(target).pending;
|
|
54
|
+
const m = measure(target);
|
|
55
|
+
if (!m.visible) return Promise.resolve(null);
|
|
56
|
+
const entry = { dirty: false, m, dpr: pickDpr(m, 8192), at: performance.now() };
|
|
57
|
+
entry.mo = new MutationObserver(() => { entry.dirty = true; });
|
|
58
|
+
entry.mo.observe(target, { subtree: true, childList: true, attributes: true, characterData: true });
|
|
59
|
+
entry.pending = snapshotElement(target, { dpr: entry.dpr, clip: m.clip }).then((s) => (entry.shot = s), () => null);
|
|
60
|
+
prepared.set(target, entry);
|
|
61
|
+
return entry.pending;
|
|
62
|
+
}
|
|
63
|
+
function takePrepared(target, m) {
|
|
64
|
+
const e = prepared.get(target);
|
|
65
|
+
if (!e) return null;
|
|
66
|
+
prepared.delete(target); e.mo.disconnect();
|
|
67
|
+
const same = (a, b) => Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) < 0.5 && Math.abs(a.w - b.w) < 0.5 && Math.abs(a.h - b.h) < 0.5;
|
|
68
|
+
if (e.dirty || !e.shot || performance.now() - e.at > 8000 || !same(e.m.page, m.page)) return null;
|
|
69
|
+
return e.shot;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ═══ Orchestration ═══════════════════════════════════════════════════════════
|
|
73
|
+
const ACTIVE = new WeakMap();
|
|
74
|
+
const DEFAULTS = { variant: 'auto', palette: 'aurora', speed: 1, particles: true, reducedMotion: null, timeout: 3000, preserveFocus: true };
|
|
75
|
+
|
|
76
|
+
class WaveTransition {
|
|
77
|
+
constructor(target, update, opts) {
|
|
78
|
+
this.target = target; this.update = update || (() => {}); this.o = { ...DEFAULTS, ...opts };
|
|
79
|
+
this._d = { ready: deferred(), update: deferred(), covered: deferred(), finished: deferred() };
|
|
80
|
+
this.ready = this._d.ready.p; this.updateCallbackDone = this._d.update.p; this.covered = this._d.covered.p; this.finished = this._d.finished.p;
|
|
81
|
+
this.metrics = {};
|
|
82
|
+
this._updated = false; this._skipped = false; this._done = false;
|
|
83
|
+
this._run().catch((err) => this._abort(err));
|
|
84
|
+
}
|
|
85
|
+
skip() { this._skipped = true; if (this._loopStarted) this._finish(); }
|
|
86
|
+
|
|
87
|
+
async _runUpdate() {
|
|
88
|
+
if (this._updated) return this._updatePromise;
|
|
89
|
+
this._updated = true;
|
|
90
|
+
const focus = this.o.preserveFocus === false ? null : captureFocus(this.target);
|
|
91
|
+
this._updatePromise = (async () => { await this.update(); restoreFocus(focus, this.target); })();
|
|
92
|
+
this._updatePromise.then(() => this._d.update.res(), (e) => this._d.update.rej(e));
|
|
93
|
+
return this._updatePromise;
|
|
94
|
+
}
|
|
95
|
+
async _run() {
|
|
96
|
+
const prev = ACTIVE.get(this.target);
|
|
97
|
+
ACTIVE.set(this.target, this);
|
|
98
|
+
if (prev) { prev.skip(); await prev.finished; }
|
|
99
|
+
const o = this.o, target = this.target;
|
|
100
|
+
const reduced = o.reducedMotion ?? matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
101
|
+
const before = measure(target);
|
|
102
|
+
const r = before.visible && target !== document.body && target !== document.documentElement ? acquire() : null;
|
|
103
|
+
this.r = r;
|
|
104
|
+
if (!r || this._skipped) return this._plain();
|
|
105
|
+
|
|
106
|
+
// 1 · "before" capture (or a still-valid pre-capture)
|
|
107
|
+
const dpr = pickDpr(before, r.maxTex);
|
|
108
|
+
let t0 = performance.now();
|
|
109
|
+
const oldShot = takePrepared(target, before) || await snapshotElement(target, { dpr: Math.min(dpr, 2), clip: before.clip });
|
|
110
|
+
this.metrics.captureBefore = Math.round(performance.now() - t0);
|
|
111
|
+
if (this._skipped) return this._plain();
|
|
112
|
+
|
|
113
|
+
// 2 · config scaled to the target + initial geometry
|
|
114
|
+
const base = { ...(PRESETS[this._variant(before)] || BASE) };
|
|
115
|
+
for (const k of Object.keys(o)) if (k in BASE && k !== 'type' && k !== 'particles') base[k] = o[k];
|
|
116
|
+
if (o.particles === false) base.particles = 0; else if (typeof o.particles === 'number') base.particles = o.particles;
|
|
117
|
+
const cfg = scaleConfig(base, before.clip.w, before.clip.h);
|
|
118
|
+
this.cfg = cfg; this.before = before; this.dpr = oldShot.dpr;
|
|
119
|
+
this.particles = makeParticles(cfg.particles, cfg); this.dots = new Float32Array(Math.max(this.particles.length, 1) * 4);
|
|
120
|
+
this.origin = this._originPage(before); // source/touch in page coordinates
|
|
121
|
+
this.canvasPage = before.page;
|
|
122
|
+
this.rects = { old: rel(before.page, before.page), new: null, shape: rel(before.el, before.page), radii: before.radii };
|
|
123
|
+
t0 = performance.now();
|
|
124
|
+
r.setLayer('old', oldShot.canvas, cfg.blurNear * this.dpr, cfg.blur * this.dpr);
|
|
125
|
+
this.metrics.upload = Math.round(performance.now() - t0);
|
|
126
|
+
this._mount(); this._hide();
|
|
127
|
+
this.mode = reduced ? 0 : 1; this.fadeOnly = reduced;
|
|
128
|
+
this._d.ready.res();
|
|
129
|
+
this._startLoop();
|
|
130
|
+
|
|
131
|
+
// 3 · DOM update during the anticipation, then "after" capture
|
|
132
|
+
await this._runUpdate();
|
|
133
|
+
await nextFrame();
|
|
134
|
+
await Promise.all([...target.querySelectorAll('img')].map((i) => i.decode?.().catch(() => {})));
|
|
135
|
+
if (this._done) return;
|
|
136
|
+
const after = measure(target);
|
|
137
|
+
t0 = performance.now();
|
|
138
|
+
const newShot = after.visible
|
|
139
|
+
? await snapshotElement(target, { dpr: this.dpr, clip: after.clip, rootOpacity: before.opacity })
|
|
140
|
+
: null;
|
|
141
|
+
this.metrics.captureAfter = Math.round(performance.now() - t0);
|
|
142
|
+
if (this._done) return;
|
|
143
|
+
if (!newShot) { this._finish(); return; }
|
|
144
|
+
r.setLayer('new', newShot.canvas, cfg.blurNear * this.dpr, cfg.blur * this.dpr);
|
|
145
|
+
// 4 · open the gate: the canvas covers the union of before/after
|
|
146
|
+
this.after = after;
|
|
147
|
+
this.canvasPage = union(before.page, after.page);
|
|
148
|
+
this.rects = { old: rel(before.page, this.canvasPage), new: rel(after.page, this.canvasPage), shape: rel(before.el, this.canvasPage), radii: before.radii };
|
|
149
|
+
this.shapes = { from: rel(before.el, this.canvasPage), to: rel(after.el, this.canvasPage), r0: before.radii, r1: after.radii };
|
|
150
|
+
this._resizeCanvas();
|
|
151
|
+
this.gateOpen = true;
|
|
152
|
+
if (this.fadeOnly) { this.mode = 3; this.fadeStart = this.now; }
|
|
153
|
+
}
|
|
154
|
+
_variant(m) {
|
|
155
|
+
if (this.o.variant !== 'auto') return this.o.variant;
|
|
156
|
+
const s = this.o.source;
|
|
157
|
+
if (!(s instanceof Element) || s === this.target) return 'center';
|
|
158
|
+
const r = s.getBoundingClientRect();
|
|
159
|
+
return r.width >= r.height * 1.4 ? 'vertical' : r.height >= r.width * 1.4 ? 'horizontal' : 'center';
|
|
160
|
+
}
|
|
161
|
+
_originPage(m) {
|
|
162
|
+
const sx = m.fixed ? 0 : scrollX, sy = m.fixed ? 0 : scrollY, o = this.o;
|
|
163
|
+
const pt = (v) => (v && 'clientX' in v ? { x: v.clientX, y: v.clientY } : v && 'x' in v ? { x: v.x, y: v.y } : null);
|
|
164
|
+
let touch = pt(o.focus);
|
|
165
|
+
if (o.focus instanceof KeyboardEvent || (o.focus && o.focus.detail === 0 && o.focus.type === 'click')) touch = null; // keyboard: no touch point, no lean
|
|
166
|
+
let src;
|
|
167
|
+
if (o.source instanceof Element && o.source !== this.target) { const r = o.source.getBoundingClientRect(); src = { x: r.left, y: r.top, w: r.width, h: r.height }; }
|
|
168
|
+
else if (o.source && 'width' in o.source) src = { x: o.source.x, y: o.source.y, w: o.source.width, h: o.source.height };
|
|
169
|
+
else {
|
|
170
|
+
const p = (!(o.source instanceof Element) && pt(o.source)) || touch || { x: m.el.x - sx + m.w / 2, y: m.el.y - sy + m.h / 2 };
|
|
171
|
+
const d = Math.max(8, Math.min(m.w, m.h) * 0.08);
|
|
172
|
+
src = { x: p.x - d, y: p.y - d, w: 2 * d, h: 2 * d };
|
|
173
|
+
}
|
|
174
|
+
return { src: { x: src.x + sx, y: src.y + sy, w: src.w, h: src.h }, touch: touch && { x: touch.x + sx, y: touch.y + sy },
|
|
175
|
+
hold: o.source instanceof Element && o.source !== this.target && this.target.contains(o.source) };
|
|
176
|
+
}
|
|
177
|
+
_mount() {
|
|
178
|
+
const c = this.r.canvas;
|
|
179
|
+
document.body.appendChild(c);
|
|
180
|
+
this._resizeCanvas();
|
|
181
|
+
}
|
|
182
|
+
_resizeCanvas() {
|
|
183
|
+
const c = this.r.canvas, p = this.canvasPage;
|
|
184
|
+
c.width = Math.max(1, Math.round(p.w * this.dpr)); c.height = Math.max(1, Math.round(p.h * this.dpr));
|
|
185
|
+
c.style.width = p.w + 'px'; c.style.height = p.h + 'px';
|
|
186
|
+
this._place();
|
|
187
|
+
}
|
|
188
|
+
_place() {
|
|
189
|
+
const p = this.canvasPage, f = this.before.fixed;
|
|
190
|
+
this.r.canvas.style.transform = `translate3d(${p.x - (f ? 0 : scrollX)}px,${p.y - (f ? 0 : scrollY)}px,0)`;
|
|
191
|
+
}
|
|
192
|
+
_hide() {
|
|
193
|
+
const s = this.target.style;
|
|
194
|
+
this._saved = ['opacity', 'transition'].map((k) => [k, s.getPropertyValue(k), s.getPropertyPriority(k)]);
|
|
195
|
+
s.setProperty('transition', 'none', 'important'); s.setProperty('opacity', '0', 'important');
|
|
196
|
+
}
|
|
197
|
+
_show() {
|
|
198
|
+
if (!this._saved) return;
|
|
199
|
+
for (const [k, v, pr] of this._saved) { if (v) this.target.style.setProperty(k, v, pr); else this.target.style.removeProperty(k); }
|
|
200
|
+
this._saved = null;
|
|
201
|
+
}
|
|
202
|
+
_startLoop() {
|
|
203
|
+
this._loopStarted = true; this.held = 0; this.start = performance.now(); this.now = this.start; this.last = this.start;
|
|
204
|
+
const tick = (now) => {
|
|
205
|
+
if (this._done) return;
|
|
206
|
+
const dt = Math.min(now - this.last, 100); this.last = now; this.now = now;
|
|
207
|
+
this.metrics.worstFrame = Math.max(this.metrics.worstFrame || 0, dt);
|
|
208
|
+
this._place();
|
|
209
|
+
let t = (now - this.start - this.held) / 1000;
|
|
210
|
+
const gateAt = this.cfg.anticipation - 1e-3;
|
|
211
|
+
if (!this.gateOpen && t >= gateAt) { // "after" capture not ready: hold the anticipation
|
|
212
|
+
this.held += t * 1000 - gateAt * 1000; t = gateAt;
|
|
213
|
+
if (now - this.start > this.o.timeout) { this._finish(); return; }
|
|
214
|
+
}
|
|
215
|
+
this.t = t;
|
|
216
|
+
if (this.mode === 3) { // reduced motion: plain crossfade
|
|
217
|
+
const k = clamp((now - this.fadeStart) / 220);
|
|
218
|
+
this.r.render({ mode: 3, fade: k, w: this.canvasPage.w, h: this.canvasPage.h, rects: this.rects, dpr: this.dpr, dotCount: 0 });
|
|
219
|
+
if (k >= 1) { this._finish(); return; }
|
|
220
|
+
} else if (this.mode === 0) {
|
|
221
|
+
this.r.render({ mode: 0, w: this.canvasPage.w, h: this.canvasPage.h, rects: this.rects, dpr: this.dpr, dotCount: 0 });
|
|
222
|
+
} else {
|
|
223
|
+
const g = { w: this.canvasPage.w, h: this.canvasPage.h, src: rel(this.origin.src, this.canvasPage),
|
|
224
|
+
touch: this.origin.touch && rel({ ...this.origin.touch, w: 0, h: 0 }, this.canvasPage), hold: this.origin.hold };
|
|
225
|
+
const s = frameState(t, this.cfg, g);
|
|
226
|
+
if (this.shapes) { // the silhouette follows the wave when the size changes
|
|
227
|
+
const k = s.moved, A = this.shapes.from, B = this.shapes.to, ra = this.shapes.r0, rb = this.shapes.r1;
|
|
228
|
+
this.rects.shape = { x: lerp(A.x, B.x, k), y: lerp(A.y, B.y, k), w: lerp(A.w, B.w, k), h: lerp(A.h, B.h, k) };
|
|
229
|
+
this.rects.radii = { tl: lerp(ra.tl, rb.tl, k), tr: lerp(ra.tr, rb.tr, k), br: lerp(ra.br, rb.br, k), bl: lerp(ra.bl, rb.bl, k) };
|
|
230
|
+
}
|
|
231
|
+
if (s.launched && !this._launched) { this._launched = true; this.o.onLaunch?.(); }
|
|
232
|
+
if (s.covered && !this._covered) { this._covered = true; this._d.covered.res(); this.o.onCovered?.(); }
|
|
233
|
+
if (s.settled && this.mode === 1) { this.mode = 4; this._show(); this.o.onSettled?.(); } // the real DOM takes over
|
|
234
|
+
let n = 0;
|
|
235
|
+
for (const P of this.particles) {
|
|
236
|
+
const q = particleAt(P, s, this.cfg);
|
|
237
|
+
if (!q || q.a <= 0.002) continue;
|
|
238
|
+
this.dots[n * 4] = q.x; this.dots[n * 4 + 1] = q.y; this.dots[n * 4 + 2] = P.size; this.dots[n * 4 + 3] = Math.min(q.a, 1); n++;
|
|
239
|
+
}
|
|
240
|
+
this.r.render({ mode: this.mode, s, cfg: this.cfg, w: this.canvasPage.w, h: this.canvasPage.h, rects: this.rects, dpr: this.dpr, dots: this.dots, dotCount: n });
|
|
241
|
+
if (t >= totalDuration(this.cfg)) { this._finish(); return; }
|
|
242
|
+
}
|
|
243
|
+
if (this._skipped) { this._finish(); return; }
|
|
244
|
+
requestAnimationFrame(tick);
|
|
245
|
+
};
|
|
246
|
+
requestAnimationFrame(tick);
|
|
247
|
+
}
|
|
248
|
+
async _plain() { // no WebGL / offscreen / skipped: just run the update
|
|
249
|
+
try { await this._runUpdate(); } finally { this._done = true; this._release(); this._d.ready.res(); this._d.covered.res(); this._d.finished.res(); }
|
|
250
|
+
}
|
|
251
|
+
async _finish() {
|
|
252
|
+
if (this._done) return;
|
|
253
|
+
this._done = true;
|
|
254
|
+
try { await this._runUpdate(); } catch {} // returns the in-flight promise if already started
|
|
255
|
+
this._show(); this._release();
|
|
256
|
+
if (!this._covered) this._d.covered.res();
|
|
257
|
+
this._d.finished.res();
|
|
258
|
+
this.o.onEnd?.(this.metrics);
|
|
259
|
+
}
|
|
260
|
+
_release() { if (this.r) { release(this.r); this.r = null; } if (ACTIVE.get(this.target) === this) ACTIVE.delete(this.target); }
|
|
261
|
+
_abort(err) {
|
|
262
|
+
console.warn('[distortion-wave] repli sans animation :', err);
|
|
263
|
+
this._done = true; this._show(); this._release();
|
|
264
|
+
(this._updated ? Promise.resolve() : this._runUpdate()).catch(() => {}).finally(() => {
|
|
265
|
+
this._d.ready.res(); this._d.covered.res(); this._d.finished.res();
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Entry point. Always runs `update` (even without WebGL, offscreen or with reduced motion). */
|
|
271
|
+
export function distortionWave(target, update, options = {}) {
|
|
272
|
+
return new WaveTransition(target, update, options);
|
|
273
|
+
}
|
|
274
|
+
/** Helper: plays a wave on `target` when `trigger` is clicked (source = trigger, focus = pointer). */
|
|
275
|
+
export function waveOnClick(trigger, target, update, options = {}) {
|
|
276
|
+
const prep = () => prepareWave(target);
|
|
277
|
+
trigger.addEventListener('pointerenter', prep);
|
|
278
|
+
trigger.addEventListener('focus', prep);
|
|
279
|
+
trigger.addEventListener('click', (e) => distortionWave(target, () => update(e), { source: trigger, focus: e, ...options }));
|
|
280
|
+
}
|
|
281
|
+
export { snapshotElement, warmupWave, PALETTES, PRESETS, scaleConfig, totalDuration, frameState };
|