paper-motion 0.1.0 → 0.2.1
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/README.md +14 -0
- package/bin/pamo.mjs +134 -4
- package/examples/halo-explainer/audio/script.txt +8 -0
- package/examples/halo-explainer/audio/words.json +554 -0
- package/examples/halo-explainer/brand/IDENTITY.md +24 -0
- package/examples/halo-explainer/brand/decisions/2026-11-08-type.md +5 -0
- package/examples/halo-explainer/brand/motion.json +9 -0
- package/examples/halo-explainer/brand/shader-set/set.json +104 -0
- package/examples/halo-explainer/brand/sound.json +33 -0
- package/examples/halo-explainer/brand/tokens.json +97 -0
- package/examples/halo-explainer/brief.md +34 -0
- package/examples/halo-explainer/poster.jpg +0 -0
- package/examples/halo-explainer/scene.html +372 -0
- package/examples/orbit-keynote/audio/script.txt +7 -0
- package/examples/orbit-keynote/audio/words.json +494 -0
- package/examples/orbit-keynote/brand/IDENTITY.md +25 -0
- package/examples/orbit-keynote/brand/decisions/2026-11-08-look.md +6 -0
- package/examples/orbit-keynote/brand/decisions/2026-11-08-voice.md +3 -0
- package/examples/orbit-keynote/brand/motion.json +9 -0
- package/examples/orbit-keynote/brand/shader-set/set.json +45 -0
- package/examples/orbit-keynote/brand/sound.json +45 -0
- package/examples/orbit-keynote/brand/tokens.json +106 -0
- package/examples/orbit-keynote/brief.md +25 -0
- package/examples/orbit-keynote/poster.jpg +0 -0
- package/examples/orbit-keynote/scene.html +323 -0
- package/lib/customize.mjs +162 -0
- package/lib/live-board.js +128 -0
- package/lib/registry.mjs +363 -0
- package/lib/render.mjs +3 -3
- package/lib/server.mjs +1 -0
- package/lib/sfx.mjs +5 -3
- package/package.json +1 -1
- package/runtime/core.js +269 -0
- package/runtime/index.js +3 -260
- package/runtime/kit.js +98 -0
- package/runtime/moves/bar-reveal.js +19 -0
- package/runtime/moves/blur-in.js +7 -0
- package/runtime/moves/cells.js +17 -0
- package/runtime/moves/crossfade.js +6 -0
- package/runtime/moves/cut.js +3 -0
- package/runtime/moves/dive-in.js +21 -0
- package/runtime/moves/draw-on.js +12 -0
- package/runtime/moves/drift.js +7 -0
- package/runtime/moves/flash.js +15 -0
- package/runtime/moves/flip-3d.js +9 -0
- package/runtime/moves/flip-in.js +12 -0
- package/runtime/moves/flip-lift.js +24 -0
- package/runtime/moves/glitch.js +16 -0
- package/runtime/moves/iris.js +12 -0
- package/runtime/moves/magic-move.js +49 -0
- package/runtime/moves/mask-reveal.js +10 -0
- package/runtime/moves/odometer.js +32 -0
- package/runtime/moves/orbit.js +8 -0
- package/runtime/moves/pop-in.js +12 -0
- package/runtime/moves/punch.js +9 -0
- package/runtime/moves/push.js +14 -0
- package/runtime/moves/rgb-split.js +7 -0
- package/runtime/moves/roll.js +20 -0
- package/runtime/moves/scan-reveal.js +10 -0
- package/runtime/moves/scramble.js +21 -0
- package/runtime/moves/shake.js +15 -0
- package/runtime/moves/slices.js +14 -0
- package/runtime/moves/snap-scroll.js +23 -0
- package/runtime/moves/squash-drop.js +21 -0
- package/runtime/moves/tile-swirl.js +29 -0
- package/runtime/moves/track-in.js +7 -0
- package/runtime/moves/type-on.js +41 -0
- package/runtime/moves/unfold.js +20 -0
- package/runtime/moves/whip-pan.js +21 -0
- package/runtime/moves/whip.js +14 -0
- package/runtime/moves/wipe.js +9 -0
- package/runtime/moves/word-rise.js +10 -0
- package/runtime/moves/zoom-through.js +13 -0
- package/runtime/moves.js +66 -146
- package/templates/move-demo/scene.html +4 -3
package/runtime/core.js
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
// paper-motion core: the pure helpers (math, easing, springs, keyframes, noise, color, text split, shapes).
|
|
2
|
+
// Browser-safe on its own: no shaders, no location, no scene. The moves import from here, so they run anywhere
|
|
3
|
+
// (the render engine, the site's live player). index.js re-exports all of it as 'paper-motion'.
|
|
4
|
+
|
|
5
|
+
const resolveEl = (el) => (typeof el === 'string' ? document.querySelector(el) : el);
|
|
6
|
+
|
|
7
|
+
/* Math, timing, easing */
|
|
8
|
+
/* ------------------------------------------------------------------ */
|
|
9
|
+
|
|
10
|
+
export const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
|
|
11
|
+
export const lerp = (a, b, p) => a + (b - a) * p;
|
|
12
|
+
export const invlerp = (a, b, v) => (b === a ? 0 : (v - a) / (b - a));
|
|
13
|
+
export const remap = (v, a, b, c, d, doClamp = true) => lerp(c, d, doClamp ? clamp(invlerp(a, b, v)) : invlerp(a, b, v));
|
|
14
|
+
export const smoothstep = (e0, e1, x) => { const p = clamp((x - e0) / (e1 - e0)); return p * p * (3 - 2 * p); };
|
|
15
|
+
export const fract = (x) => x - Math.floor(x);
|
|
16
|
+
export const TAU = Math.PI * 2;
|
|
17
|
+
|
|
18
|
+
/** 0..1 progress of t through [start, end], eased. */
|
|
19
|
+
export const progress = (t, start, end, easing = ease.linear) => easing(clamp(invlerp(start, end, t)));
|
|
20
|
+
|
|
21
|
+
/** Progress for item i of a staggered group. */
|
|
22
|
+
export const stagger = (t, i, { start = 0, each = 0.08, duration = 0.8, easing } = {}) =>
|
|
23
|
+
progress(t, start + i * each, start + i * each + duration, easing);
|
|
24
|
+
|
|
25
|
+
/** 0..1 phase of t in a cycle of `period` seconds. */
|
|
26
|
+
export const phase = (t, period) => fract(t / period);
|
|
27
|
+
/** Sine oscillator in -1..1. Use period = duration / n for perfect loops. */
|
|
28
|
+
export const osc = (t, period, offset = 0) => Math.sin(TAU * (t / period + offset));
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Keyframes: keys(t, [[0, 0], [2, 1, ease.outExpo], [5, '#ff0044']])
|
|
32
|
+
* Values: numbers, arrays of numbers, or hex colors (mixed in OKLab).
|
|
33
|
+
* The easing on a key applies to the segment arriving at that key.
|
|
34
|
+
*/
|
|
35
|
+
export function keys(t, frames) {
|
|
36
|
+
if (t <= frames[0][0]) return frames[0][1];
|
|
37
|
+
for (let i = 1; i < frames.length; i++) {
|
|
38
|
+
const [t1, v1, e = ease.inOutCubic] = frames[i];
|
|
39
|
+
const [t0, v0] = frames[i - 1];
|
|
40
|
+
if (t <= t1) return mix(v0, v1, e(invlerp(t0, t1, t)));
|
|
41
|
+
}
|
|
42
|
+
return frames[frames.length - 1][1];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function mix(a, b, p) {
|
|
46
|
+
if (typeof a === 'number') return lerp(a, b, p);
|
|
47
|
+
if (Array.isArray(a)) return a.map((v, i) => mix(v, b[i], p));
|
|
48
|
+
if (typeof a === 'string') return mixColor(a, b, p);
|
|
49
|
+
return p < 0.5 ? a : b;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const c1 = 1.70158, c3 = c1 + 1;
|
|
53
|
+
export const ease = {
|
|
54
|
+
linear: (x) => x,
|
|
55
|
+
inSine: (x) => 1 - Math.cos((x * Math.PI) / 2),
|
|
56
|
+
outSine: (x) => Math.sin((x * Math.PI) / 2),
|
|
57
|
+
inOutSine: (x) => -(Math.cos(Math.PI * x) - 1) / 2,
|
|
58
|
+
inQuad: (x) => x * x,
|
|
59
|
+
outQuad: (x) => 1 - (1 - x) ** 2,
|
|
60
|
+
inOutQuad: (x) => (x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2),
|
|
61
|
+
inCubic: (x) => x ** 3,
|
|
62
|
+
outCubic: (x) => 1 - (1 - x) ** 3,
|
|
63
|
+
inOutCubic: (x) => (x < 0.5 ? 4 * x ** 3 : 1 - (-2 * x + 2) ** 3 / 2),
|
|
64
|
+
inQuart: (x) => x ** 4,
|
|
65
|
+
outQuart: (x) => 1 - (1 - x) ** 4,
|
|
66
|
+
inOutQuart: (x) => (x < 0.5 ? 8 * x ** 4 : 1 - (-2 * x + 2) ** 4 / 2),
|
|
67
|
+
inQuint: (x) => x ** 5,
|
|
68
|
+
outQuint: (x) => 1 - (1 - x) ** 5,
|
|
69
|
+
inOutQuint: (x) => (x < 0.5 ? 16 * x ** 5 : 1 - (-2 * x + 2) ** 5 / 2),
|
|
70
|
+
inExpo: (x) => (x === 0 ? 0 : 2 ** (10 * x - 10)),
|
|
71
|
+
outExpo: (x) => (x === 1 ? 1 : 1 - 2 ** (-10 * x)),
|
|
72
|
+
inOutExpo: (x) => (x === 0 ? 0 : x === 1 ? 1 : x < 0.5 ? 2 ** (20 * x - 10) / 2 : (2 - 2 ** (-20 * x + 10)) / 2),
|
|
73
|
+
inCirc: (x) => 1 - Math.sqrt(1 - x * x),
|
|
74
|
+
outCirc: (x) => Math.sqrt(1 - (x - 1) ** 2),
|
|
75
|
+
inOutCirc: (x) => (x < 0.5 ? (1 - Math.sqrt(1 - (2 * x) ** 2)) / 2 : (Math.sqrt(1 - (-2 * x + 2) ** 2) + 1) / 2),
|
|
76
|
+
outBack: (x) => 1 + c3 * (x - 1) ** 3 + c1 * (x - 1) ** 2,
|
|
77
|
+
inOutBack: (x) => { const c2 = c1 * 1.525; return x < 0.5 ? ((2 * x) ** 2 * ((c2 + 1) * 2 * x - c2)) / 2 : ((2 * x - 2) ** 2 * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2; },
|
|
78
|
+
/** CSS-style cubic-bezier(x1, y1, x2, y2). */
|
|
79
|
+
bezier(x1, y1, x2, y2) {
|
|
80
|
+
const A = (a, b) => 1 - 3 * b + 3 * a, B = (a, b) => 3 * b - 6 * a, C = (a) => 3 * a;
|
|
81
|
+
const calc = (t, a, b) => ((A(a, b) * t + B(a, b)) * t + C(a)) * t;
|
|
82
|
+
const slope = (t, a, b) => 3 * A(a, b) * t * t + 2 * B(a, b) * t + C(a);
|
|
83
|
+
return (x) => {
|
|
84
|
+
if (x <= 0 || x >= 1) return x;
|
|
85
|
+
let t = x;
|
|
86
|
+
for (let i = 0; i < 8; i++) { const s = slope(t, x1, x2); if (Math.abs(s) < 1e-6) break; t -= (calc(t, x1, x2) - x) / s; }
|
|
87
|
+
return calc(clamp(t), y1, y2);
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// After Effects-style curves (graph editor feel)
|
|
93
|
+
ease.easy = ease.bezier(0.33, 0, 0.67, 1); // AE "Easy Ease" (33% influence)
|
|
94
|
+
ease.smooth = ease.bezier(0.65, 0, 0.35, 1); // stronger in/out, motion-design default
|
|
95
|
+
ease.snappy = ease.bezier(0.16, 1, 0.3, 1); // fast start, long soft landing (≈ expo out)
|
|
96
|
+
ease.anticipate = ease.bezier(0.36, -0.35, 0.3, 1); // tiny pull-back before moving
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Physical spring, analytic (deterministic). Returns 0→1 with overshoot for
|
|
100
|
+
* seconds `dt` since the spring was released (negative dt → 0).
|
|
101
|
+
* Lower damping = more bounce. stiffness 170 / damping 26 is a crisp UI spring.
|
|
102
|
+
*/
|
|
103
|
+
export function spring(dt, { stiffness = 170, damping = 20, mass = 1 } = {}) {
|
|
104
|
+
if (dt <= 0) return 0;
|
|
105
|
+
const w0 = Math.sqrt(stiffness / mass), zeta = damping / (2 * Math.sqrt(stiffness * mass));
|
|
106
|
+
if (zeta < 1) {
|
|
107
|
+
const wd = w0 * Math.sqrt(1 - zeta * zeta);
|
|
108
|
+
return 1 - Math.exp(-zeta * w0 * dt) * (Math.cos(wd * dt) + ((zeta * w0) / wd) * Math.sin(wd * dt));
|
|
109
|
+
}
|
|
110
|
+
return 1 - Math.exp(-w0 * dt) * (1 + w0 * dt);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* ------------------------------------------------------------------ */
|
|
114
|
+
/* Randomness and noise (seeded, deterministic) */
|
|
115
|
+
/* ------------------------------------------------------------------ */
|
|
116
|
+
|
|
117
|
+
export function rng(seed = 1) {
|
|
118
|
+
let a = typeof seed === 'string' ? [...seed].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619), 2166136261) : seed >>> 0;
|
|
119
|
+
const next = () => {
|
|
120
|
+
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
|
121
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
122
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
123
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
next,
|
|
127
|
+
range: (lo, hi) => lo + (hi - lo) * next(),
|
|
128
|
+
int: (lo, hi) => Math.floor(lo + (hi - lo + 1) * next()),
|
|
129
|
+
pick: (arr) => arr[Math.floor(next() * arr.length)],
|
|
130
|
+
sign: () => (next() < 0.5 ? -1 : 1),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const hash3 = (x, y, z) => {
|
|
135
|
+
let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(z, 2147483647);
|
|
136
|
+
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
|
137
|
+
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
|
|
138
|
+
};
|
|
139
|
+
/** Smooth 3D value noise in 0..1. noise(x), noise(x, y), noise(x, y, z). */
|
|
140
|
+
export function noise(x, y = 0, z = 0) {
|
|
141
|
+
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
|
|
142
|
+
const xf = x - xi, yf = y - yi, zf = z - zi;
|
|
143
|
+
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf), w = zf * zf * (3 - 2 * zf);
|
|
144
|
+
const n = (a, b, c) => hash3(xi + a, yi + b, zi + c);
|
|
145
|
+
const x00 = lerp(n(0, 0, 0), n(1, 0, 0), u), x10 = lerp(n(0, 1, 0), n(1, 1, 0), u);
|
|
146
|
+
const x01 = lerp(n(0, 0, 1), n(1, 0, 1), u), x11 = lerp(n(0, 1, 1), n(1, 1, 1), u);
|
|
147
|
+
return lerp(lerp(x00, x10, v), lerp(x01, x11, v), w);
|
|
148
|
+
}
|
|
149
|
+
/** Fractal noise (octaves of value noise), 0..1. */
|
|
150
|
+
export function fbm(x, y = 0, z = 0, octaves = 4) {
|
|
151
|
+
let s = 0, amp = 0.5, f = 1, norm = 0;
|
|
152
|
+
for (let i = 0; i < octaves; i++) { s += amp * noise(x * f, y * f, z * f); norm += amp; amp *= 0.5; f *= 2; }
|
|
153
|
+
return s / norm;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ------------------------------------------------------------------ */
|
|
157
|
+
/* Color (OKLab mixing) */
|
|
158
|
+
/* ------------------------------------------------------------------ */
|
|
159
|
+
|
|
160
|
+
function hexToRgb(hex) {
|
|
161
|
+
let h = hex.replace('#', '');
|
|
162
|
+
if (h.length <= 4) h = [...h].map((c) => c + c).join('');
|
|
163
|
+
const n = parseInt(h.slice(0, 6), 16);
|
|
164
|
+
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
|
|
165
|
+
return [(n >> 16) & 255, (n >> 8) & 255, n & 255, a];
|
|
166
|
+
}
|
|
167
|
+
const toLin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
|
168
|
+
const toSrgb = (c) => Math.round(255 * clamp(c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055));
|
|
169
|
+
function rgbToOklab([r, g, b]) {
|
|
170
|
+
r = toLin(r); g = toLin(g); b = toLin(b);
|
|
171
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
172
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
173
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
174
|
+
return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
|
|
175
|
+
}
|
|
176
|
+
function oklabToRgb([L, A, B]) {
|
|
177
|
+
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
|
|
178
|
+
const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
|
|
179
|
+
const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
|
|
180
|
+
return [toSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), toSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), toSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)];
|
|
181
|
+
}
|
|
182
|
+
/** Perceptual color mix of two hex colors -> hex. */
|
|
183
|
+
export function mixColor(a, b, p) {
|
|
184
|
+
const A = hexToRgb(a), B = hexToRgb(b);
|
|
185
|
+
const [r, g, bl] = oklabToRgb(mix(rgbToOklab(A), rgbToOklab(B), p));
|
|
186
|
+
const al = lerp(A[3], B[3], p);
|
|
187
|
+
const hx = (n) => n.toString(16).padStart(2, '0');
|
|
188
|
+
return `#${hx(r)}${hx(g)}${hx(bl)}${al < 1 ? hx(Math.round(al * 255)) : ''}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ------------------------------------------------------------------ */
|
|
192
|
+
/* Typography */
|
|
193
|
+
/* ------------------------------------------------------------------ */
|
|
194
|
+
|
|
195
|
+
/** Wrap each word or char of an element in spans for staggered motion. Returns the spans. */
|
|
196
|
+
export function split(el, by = 'chars') {
|
|
197
|
+
el = resolveEl(el);
|
|
198
|
+
const text = el.textContent;
|
|
199
|
+
el.textContent = '';
|
|
200
|
+
el.setAttribute('aria-label', text);
|
|
201
|
+
const out = [];
|
|
202
|
+
const words = text.split(/(\s+)/);
|
|
203
|
+
for (const w of words) {
|
|
204
|
+
if (/^\s+$/.test(w)) { el.appendChild(document.createTextNode(w)); continue; }
|
|
205
|
+
const ws = document.createElement('span');
|
|
206
|
+
ws.style.display = 'inline-block';
|
|
207
|
+
ws.style.whiteSpace = 'pre';
|
|
208
|
+
if (by === 'words') { ws.textContent = w; out.push(ws); }
|
|
209
|
+
else for (const ch of w) { const c = document.createElement('span'); c.style.display = 'inline-block'; c.textContent = ch; ws.appendChild(c); out.push(c); }
|
|
210
|
+
el.appendChild(ws);
|
|
211
|
+
}
|
|
212
|
+
return out;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* ------------------------------------------------------------------ */
|
|
216
|
+
/* Shapes */
|
|
217
|
+
/* ------------------------------------------------------------------ */
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* SVG path `d` for a hand-cut rounded rectangle: the "organic" shape language.
|
|
221
|
+
* Deterministic for a given seed, so a shape keeps its edges across frames and renders.
|
|
222
|
+
* @param {number} w,h size in px
|
|
223
|
+
* @param {object} [o]
|
|
224
|
+
* @param {number} [o.radius] corner radius in px (default 24)
|
|
225
|
+
* @param {number} [o.wobble] edge irregularity in px (default 0.6% of the short side; keep it low)
|
|
226
|
+
* @param {number} [o.seed] changes the irregularity, not its amount
|
|
227
|
+
*/
|
|
228
|
+
export function organicPath(w, h, { radius = 24, wobble, seed = 1 } = {}) {
|
|
229
|
+
const amp = wobble ?? Math.min(w, h) * 0.014;
|
|
230
|
+
// draw on a box inset by the wobble, so the shape never leaves w×h (and never gets clipped flat)
|
|
231
|
+
const W0 = w, H0 = h; w -= amp * 2; h -= amp * 2;
|
|
232
|
+
const r = Math.min(radius, w / 2, h / 2);
|
|
233
|
+
const per = 2 * (w + h - 4 * r) + 2 * Math.PI * r;
|
|
234
|
+
const n = Math.max(16, Math.round(per / 40));
|
|
235
|
+
const pt = (d) => {
|
|
236
|
+
// walk the rounded-rect perimeter clockwise from the top-left corner's end
|
|
237
|
+
const segs = [[w - 2 * r, 'top'], [Math.PI * r / 2, 'tr'], [h - 2 * r, 'right'], [Math.PI * r / 2, 'br'], [w - 2 * r, 'bottom'], [Math.PI * r / 2, 'bl'], [h - 2 * r, 'left'], [Math.PI * r / 2, 'tl']];
|
|
238
|
+
for (const [len, k] of segs) {
|
|
239
|
+
if (d > len) { d -= len; continue; }
|
|
240
|
+
const a = r ? d / r : 0;
|
|
241
|
+
switch (k) {
|
|
242
|
+
case 'top': return [r + d, 0, 0, -1];
|
|
243
|
+
case 'tr': return [w - r + r * Math.sin(a), r - r * Math.cos(a), Math.sin(a), -Math.cos(a)];
|
|
244
|
+
case 'right': return [w, r + d, 1, 0];
|
|
245
|
+
case 'br': return [w - r + r * Math.cos(a), h - r + r * Math.sin(a), Math.cos(a), Math.sin(a)];
|
|
246
|
+
case 'bottom': return [w - r - d, h, 0, 1];
|
|
247
|
+
case 'bl': return [r - r * Math.sin(a), h - r + r * Math.cos(a), -Math.sin(a), Math.cos(a)];
|
|
248
|
+
case 'left': return [0, h - r - d, -1, 0];
|
|
249
|
+
default: return [r - r * Math.cos(a), r - r * Math.sin(a), -Math.cos(a), -Math.sin(a)];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return [r, 0, 0, -1];
|
|
253
|
+
};
|
|
254
|
+
const P = Array.from({ length: n }, (_, i) => {
|
|
255
|
+
const [x, y, nx, ny] = pt((i / n) * per);
|
|
256
|
+
const o = amp * (noise(i * 0.55, seed * 7.13) * 2 - 1);
|
|
257
|
+
return [x + nx * o + amp, y + ny * o + amp];
|
|
258
|
+
});
|
|
259
|
+
void W0; void H0;
|
|
260
|
+
// closed Catmull-Rom through the points, as cubic Béziers
|
|
261
|
+
let d = `M${P[0][0].toFixed(2)},${P[0][1].toFixed(2)}`;
|
|
262
|
+
for (let i = 0; i < n; i++) {
|
|
263
|
+
const p0 = P[(i - 1 + n) % n], p1 = P[i], p2 = P[(i + 1) % n], p3 = P[(i + 2) % n];
|
|
264
|
+
const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
|
|
265
|
+
const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
|
|
266
|
+
d += `C${c1[0].toFixed(2)},${c1[1].toFixed(2)} ${c2[0].toFixed(2)},${c2[1].toFixed(2)} ${p2[0].toFixed(2)},${p2[1].toFixed(2)}`;
|
|
267
|
+
}
|
|
268
|
+
return d + 'Z';
|
|
269
|
+
}
|
package/runtime/index.js
CHANGED
|
@@ -323,269 +323,12 @@ function child(parent) {
|
|
|
323
323
|
}
|
|
324
324
|
|
|
325
325
|
/* ------------------------------------------------------------------ */
|
|
326
|
-
/* Math, timing, easing */
|
|
327
326
|
/* ------------------------------------------------------------------ */
|
|
328
|
-
|
|
329
|
-
export const clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));
|
|
330
|
-
export const lerp = (a, b, p) => a + (b - a) * p;
|
|
331
|
-
export const invlerp = (a, b, v) => (b === a ? 0 : (v - a) / (b - a));
|
|
332
|
-
export const remap = (v, a, b, c, d, doClamp = true) => lerp(c, d, doClamp ? clamp(invlerp(a, b, v)) : invlerp(a, b, v));
|
|
333
|
-
export const smoothstep = (e0, e1, x) => { const p = clamp((x - e0) / (e1 - e0)); return p * p * (3 - 2 * p); };
|
|
334
|
-
export const fract = (x) => x - Math.floor(x);
|
|
335
|
-
export const TAU = Math.PI * 2;
|
|
336
|
-
|
|
337
|
-
/** 0..1 progress of t through [start, end], eased. */
|
|
338
|
-
export const progress = (t, start, end, easing = ease.linear) => easing(clamp(invlerp(start, end, t)));
|
|
339
|
-
|
|
340
|
-
/** Progress for item i of a staggered group. */
|
|
341
|
-
export const stagger = (t, i, { start = 0, each = 0.08, duration = 0.8, easing } = {}) =>
|
|
342
|
-
progress(t, start + i * each, start + i * each + duration, easing);
|
|
343
|
-
|
|
344
|
-
/** 0..1 phase of t in a cycle of `period` seconds. */
|
|
345
|
-
export const phase = (t, period) => fract(t / period);
|
|
346
|
-
/** Sine oscillator in -1..1. Use period = duration / n for perfect loops. */
|
|
347
|
-
export const osc = (t, period, offset = 0) => Math.sin(TAU * (t / period + offset));
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* Keyframes: keys(t, [[0, 0], [2, 1, ease.outExpo], [5, '#ff0044']])
|
|
351
|
-
* Values: numbers, arrays of numbers, or hex colors (mixed in OKLab).
|
|
352
|
-
* The easing on a key applies to the segment arriving at that key.
|
|
353
|
-
*/
|
|
354
|
-
export function keys(t, frames) {
|
|
355
|
-
if (t <= frames[0][0]) return frames[0][1];
|
|
356
|
-
for (let i = 1; i < frames.length; i++) {
|
|
357
|
-
const [t1, v1, e = ease.inOutCubic] = frames[i];
|
|
358
|
-
const [t0, v0] = frames[i - 1];
|
|
359
|
-
if (t <= t1) return mix(v0, v1, e(invlerp(t0, t1, t)));
|
|
360
|
-
}
|
|
361
|
-
return frames[frames.length - 1][1];
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
export function mix(a, b, p) {
|
|
365
|
-
if (typeof a === 'number') return lerp(a, b, p);
|
|
366
|
-
if (Array.isArray(a)) return a.map((v, i) => mix(v, b[i], p));
|
|
367
|
-
if (typeof a === 'string') return mixColor(a, b, p);
|
|
368
|
-
return p < 0.5 ? a : b;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const c1 = 1.70158, c3 = c1 + 1;
|
|
372
|
-
export const ease = {
|
|
373
|
-
linear: (x) => x,
|
|
374
|
-
inSine: (x) => 1 - Math.cos((x * Math.PI) / 2),
|
|
375
|
-
outSine: (x) => Math.sin((x * Math.PI) / 2),
|
|
376
|
-
inOutSine: (x) => -(Math.cos(Math.PI * x) - 1) / 2,
|
|
377
|
-
inQuad: (x) => x * x,
|
|
378
|
-
outQuad: (x) => 1 - (1 - x) ** 2,
|
|
379
|
-
inOutQuad: (x) => (x < 0.5 ? 2 * x * x : 1 - (-2 * x + 2) ** 2 / 2),
|
|
380
|
-
inCubic: (x) => x ** 3,
|
|
381
|
-
outCubic: (x) => 1 - (1 - x) ** 3,
|
|
382
|
-
inOutCubic: (x) => (x < 0.5 ? 4 * x ** 3 : 1 - (-2 * x + 2) ** 3 / 2),
|
|
383
|
-
inQuart: (x) => x ** 4,
|
|
384
|
-
outQuart: (x) => 1 - (1 - x) ** 4,
|
|
385
|
-
inOutQuart: (x) => (x < 0.5 ? 8 * x ** 4 : 1 - (-2 * x + 2) ** 4 / 2),
|
|
386
|
-
inQuint: (x) => x ** 5,
|
|
387
|
-
outQuint: (x) => 1 - (1 - x) ** 5,
|
|
388
|
-
inOutQuint: (x) => (x < 0.5 ? 16 * x ** 5 : 1 - (-2 * x + 2) ** 5 / 2),
|
|
389
|
-
inExpo: (x) => (x === 0 ? 0 : 2 ** (10 * x - 10)),
|
|
390
|
-
outExpo: (x) => (x === 1 ? 1 : 1 - 2 ** (-10 * x)),
|
|
391
|
-
inOutExpo: (x) => (x === 0 ? 0 : x === 1 ? 1 : x < 0.5 ? 2 ** (20 * x - 10) / 2 : (2 - 2 ** (-20 * x + 10)) / 2),
|
|
392
|
-
inCirc: (x) => 1 - Math.sqrt(1 - x * x),
|
|
393
|
-
outCirc: (x) => Math.sqrt(1 - (x - 1) ** 2),
|
|
394
|
-
inOutCirc: (x) => (x < 0.5 ? (1 - Math.sqrt(1 - (2 * x) ** 2)) / 2 : (Math.sqrt(1 - (-2 * x + 2) ** 2) + 1) / 2),
|
|
395
|
-
outBack: (x) => 1 + c3 * (x - 1) ** 3 + c1 * (x - 1) ** 2,
|
|
396
|
-
inOutBack: (x) => { const c2 = c1 * 1.525; return x < 0.5 ? ((2 * x) ** 2 * ((c2 + 1) * 2 * x - c2)) / 2 : ((2 * x - 2) ** 2 * ((c2 + 1) * (x * 2 - 2) + c2) + 2) / 2; },
|
|
397
|
-
/** CSS-style cubic-bezier(x1, y1, x2, y2). */
|
|
398
|
-
bezier(x1, y1, x2, y2) {
|
|
399
|
-
const A = (a, b) => 1 - 3 * b + 3 * a, B = (a, b) => 3 * b - 6 * a, C = (a) => 3 * a;
|
|
400
|
-
const calc = (t, a, b) => ((A(a, b) * t + B(a, b)) * t + C(a)) * t;
|
|
401
|
-
const slope = (t, a, b) => 3 * A(a, b) * t * t + 2 * B(a, b) * t + C(a);
|
|
402
|
-
return (x) => {
|
|
403
|
-
if (x <= 0 || x >= 1) return x;
|
|
404
|
-
let t = x;
|
|
405
|
-
for (let i = 0; i < 8; i++) { const s = slope(t, x1, x2); if (Math.abs(s) < 1e-6) break; t -= (calc(t, x1, x2) - x) / s; }
|
|
406
|
-
return calc(clamp(t), y1, y2);
|
|
407
|
-
};
|
|
408
|
-
},
|
|
409
|
-
};
|
|
410
|
-
|
|
411
|
-
// After Effects-style curves (graph editor feel)
|
|
412
|
-
ease.easy = ease.bezier(0.33, 0, 0.67, 1); // AE "Easy Ease" (33% influence)
|
|
413
|
-
ease.smooth = ease.bezier(0.65, 0, 0.35, 1); // stronger in/out, motion-design default
|
|
414
|
-
ease.snappy = ease.bezier(0.16, 1, 0.3, 1); // fast start, long soft landing (≈ expo out)
|
|
415
|
-
ease.anticipate = ease.bezier(0.36, -0.35, 0.3, 1); // tiny pull-back before moving
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Physical spring, analytic (deterministic). Returns 0→1 with overshoot for
|
|
419
|
-
* seconds `dt` since the spring was released (negative dt → 0).
|
|
420
|
-
* Lower damping = more bounce. stiffness 170 / damping 26 is a crisp UI spring.
|
|
421
|
-
*/
|
|
422
|
-
export function spring(dt, { stiffness = 170, damping = 20, mass = 1 } = {}) {
|
|
423
|
-
if (dt <= 0) return 0;
|
|
424
|
-
const w0 = Math.sqrt(stiffness / mass), zeta = damping / (2 * Math.sqrt(stiffness * mass));
|
|
425
|
-
if (zeta < 1) {
|
|
426
|
-
const wd = w0 * Math.sqrt(1 - zeta * zeta);
|
|
427
|
-
return 1 - Math.exp(-zeta * w0 * dt) * (Math.cos(wd * dt) + ((zeta * w0) / wd) * Math.sin(wd * dt));
|
|
428
|
-
}
|
|
429
|
-
return 1 - Math.exp(-w0 * dt) * (1 + w0 * dt);
|
|
430
|
-
}
|
|
431
|
-
|
|
327
|
+
/* Helpers (core.js: math, easing, springs, noise, color, text, shapes) */
|
|
432
328
|
/* ------------------------------------------------------------------ */
|
|
433
|
-
/* Randomness and noise (seeded, deterministic) */
|
|
434
|
-
/* ------------------------------------------------------------------ */
|
|
435
|
-
|
|
436
|
-
export function rng(seed = 1) {
|
|
437
|
-
let a = typeof seed === 'string' ? [...seed].reduce((h, c) => Math.imul(h ^ c.charCodeAt(0), 16777619), 2166136261) : seed >>> 0;
|
|
438
|
-
const next = () => {
|
|
439
|
-
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
|
440
|
-
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
441
|
-
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
442
|
-
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
443
|
-
};
|
|
444
|
-
return {
|
|
445
|
-
next,
|
|
446
|
-
range: (lo, hi) => lo + (hi - lo) * next(),
|
|
447
|
-
int: (lo, hi) => Math.floor(lo + (hi - lo + 1) * next()),
|
|
448
|
-
pick: (arr) => arr[Math.floor(next() * arr.length)],
|
|
449
|
-
sign: () => (next() < 0.5 ? -1 : 1),
|
|
450
|
-
};
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
const hash3 = (x, y, z) => {
|
|
454
|
-
let h = Math.imul(x, 374761393) ^ Math.imul(y, 668265263) ^ Math.imul(z, 2147483647);
|
|
455
|
-
h = Math.imul(h ^ (h >>> 13), 1274126177);
|
|
456
|
-
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
|
|
457
|
-
};
|
|
458
|
-
/** Smooth 3D value noise in 0..1. noise(x), noise(x, y), noise(x, y, z). */
|
|
459
|
-
export function noise(x, y = 0, z = 0) {
|
|
460
|
-
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
|
|
461
|
-
const xf = x - xi, yf = y - yi, zf = z - zi;
|
|
462
|
-
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf), w = zf * zf * (3 - 2 * zf);
|
|
463
|
-
const n = (a, b, c) => hash3(xi + a, yi + b, zi + c);
|
|
464
|
-
const x00 = lerp(n(0, 0, 0), n(1, 0, 0), u), x10 = lerp(n(0, 1, 0), n(1, 1, 0), u);
|
|
465
|
-
const x01 = lerp(n(0, 0, 1), n(1, 0, 1), u), x11 = lerp(n(0, 1, 1), n(1, 1, 1), u);
|
|
466
|
-
return lerp(lerp(x00, x10, v), lerp(x01, x11, v), w);
|
|
467
|
-
}
|
|
468
|
-
/** Fractal noise (octaves of value noise), 0..1. */
|
|
469
|
-
export function fbm(x, y = 0, z = 0, octaves = 4) {
|
|
470
|
-
let s = 0, amp = 0.5, f = 1, norm = 0;
|
|
471
|
-
for (let i = 0; i < octaves; i++) { s += amp * noise(x * f, y * f, z * f); norm += amp; amp *= 0.5; f *= 2; }
|
|
472
|
-
return s / norm;
|
|
473
|
-
}
|
|
474
329
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
/* ------------------------------------------------------------------ */
|
|
478
|
-
|
|
479
|
-
function hexToRgb(hex) {
|
|
480
|
-
let h = hex.replace('#', '');
|
|
481
|
-
if (h.length <= 4) h = [...h].map((c) => c + c).join('');
|
|
482
|
-
const n = parseInt(h.slice(0, 6), 16);
|
|
483
|
-
const a = h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1;
|
|
484
|
-
return [(n >> 16) & 255, (n >> 8) & 255, n & 255, a];
|
|
485
|
-
}
|
|
486
|
-
const toLin = (c) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
|
|
487
|
-
const toSrgb = (c) => Math.round(255 * clamp(c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055));
|
|
488
|
-
function rgbToOklab([r, g, b]) {
|
|
489
|
-
r = toLin(r); g = toLin(g); b = toLin(b);
|
|
490
|
-
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
491
|
-
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
492
|
-
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
493
|
-
return [0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s];
|
|
494
|
-
}
|
|
495
|
-
function oklabToRgb([L, A, B]) {
|
|
496
|
-
const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
|
|
497
|
-
const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
|
|
498
|
-
const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
|
|
499
|
-
return [toSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s), toSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s), toSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)];
|
|
500
|
-
}
|
|
501
|
-
/** Perceptual color mix of two hex colors -> hex. */
|
|
502
|
-
export function mixColor(a, b, p) {
|
|
503
|
-
const A = hexToRgb(a), B = hexToRgb(b);
|
|
504
|
-
const [r, g, bl] = oklabToRgb(mix(rgbToOklab(A), rgbToOklab(B), p));
|
|
505
|
-
const al = lerp(A[3], B[3], p);
|
|
506
|
-
const hx = (n) => n.toString(16).padStart(2, '0');
|
|
507
|
-
return `#${hx(r)}${hx(g)}${hx(bl)}${al < 1 ? hx(Math.round(al * 255)) : ''}`;
|
|
508
|
-
}
|
|
509
|
-
|
|
510
|
-
/* ------------------------------------------------------------------ */
|
|
511
|
-
/* Typography */
|
|
512
|
-
/* ------------------------------------------------------------------ */
|
|
513
|
-
|
|
514
|
-
/** Wrap each word or char of an element in spans for staggered motion. Returns the spans. */
|
|
515
|
-
export function split(el, by = 'chars') {
|
|
516
|
-
el = resolveEl(el);
|
|
517
|
-
const text = el.textContent;
|
|
518
|
-
el.textContent = '';
|
|
519
|
-
el.setAttribute('aria-label', text);
|
|
520
|
-
const out = [];
|
|
521
|
-
const words = text.split(/(\s+)/);
|
|
522
|
-
for (const w of words) {
|
|
523
|
-
if (/^\s+$/.test(w)) { el.appendChild(document.createTextNode(w)); continue; }
|
|
524
|
-
const ws = document.createElement('span');
|
|
525
|
-
ws.style.display = 'inline-block';
|
|
526
|
-
ws.style.whiteSpace = 'pre';
|
|
527
|
-
if (by === 'words') { ws.textContent = w; out.push(ws); }
|
|
528
|
-
else for (const ch of w) { const c = document.createElement('span'); c.style.display = 'inline-block'; c.textContent = ch; ws.appendChild(c); out.push(c); }
|
|
529
|
-
el.appendChild(ws);
|
|
530
|
-
}
|
|
531
|
-
return out;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
/* ------------------------------------------------------------------ */
|
|
535
|
-
/* Shapes */
|
|
536
|
-
/* ------------------------------------------------------------------ */
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* SVG path `d` for a hand-cut rounded rectangle: the "organic" shape language.
|
|
540
|
-
* Deterministic for a given seed, so a shape keeps its edges across frames and renders.
|
|
541
|
-
* @param {number} w,h size in px
|
|
542
|
-
* @param {object} [o]
|
|
543
|
-
* @param {number} [o.radius] corner radius in px (default 24)
|
|
544
|
-
* @param {number} [o.wobble] edge irregularity in px (default 0.6% of the short side; keep it low)
|
|
545
|
-
* @param {number} [o.seed] changes the irregularity, not its amount
|
|
546
|
-
*/
|
|
547
|
-
export function organicPath(w, h, { radius = 24, wobble, seed = 1 } = {}) {
|
|
548
|
-
const amp = wobble ?? Math.min(w, h) * 0.014;
|
|
549
|
-
// draw on a box inset by the wobble, so the shape never leaves w×h (and never gets clipped flat)
|
|
550
|
-
const W0 = w, H0 = h; w -= amp * 2; h -= amp * 2;
|
|
551
|
-
const r = Math.min(radius, w / 2, h / 2);
|
|
552
|
-
const per = 2 * (w + h - 4 * r) + 2 * Math.PI * r;
|
|
553
|
-
const n = Math.max(16, Math.round(per / 40));
|
|
554
|
-
const pt = (d) => {
|
|
555
|
-
// walk the rounded-rect perimeter clockwise from the top-left corner's end
|
|
556
|
-
const segs = [[w - 2 * r, 'top'], [Math.PI * r / 2, 'tr'], [h - 2 * r, 'right'], [Math.PI * r / 2, 'br'], [w - 2 * r, 'bottom'], [Math.PI * r / 2, 'bl'], [h - 2 * r, 'left'], [Math.PI * r / 2, 'tl']];
|
|
557
|
-
for (const [len, k] of segs) {
|
|
558
|
-
if (d > len) { d -= len; continue; }
|
|
559
|
-
const a = r ? d / r : 0;
|
|
560
|
-
switch (k) {
|
|
561
|
-
case 'top': return [r + d, 0, 0, -1];
|
|
562
|
-
case 'tr': return [w - r + r * Math.sin(a), r - r * Math.cos(a), Math.sin(a), -Math.cos(a)];
|
|
563
|
-
case 'right': return [w, r + d, 1, 0];
|
|
564
|
-
case 'br': return [w - r + r * Math.cos(a), h - r + r * Math.sin(a), Math.cos(a), Math.sin(a)];
|
|
565
|
-
case 'bottom': return [w - r - d, h, 0, 1];
|
|
566
|
-
case 'bl': return [r - r * Math.sin(a), h - r + r * Math.cos(a), -Math.sin(a), Math.cos(a)];
|
|
567
|
-
case 'left': return [0, h - r - d, -1, 0];
|
|
568
|
-
default: return [r - r * Math.cos(a), r - r * Math.sin(a), -Math.cos(a), -Math.sin(a)];
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
return [r, 0, 0, -1];
|
|
572
|
-
};
|
|
573
|
-
const P = Array.from({ length: n }, (_, i) => {
|
|
574
|
-
const [x, y, nx, ny] = pt((i / n) * per);
|
|
575
|
-
const o = amp * (noise(i * 0.55, seed * 7.13) * 2 - 1);
|
|
576
|
-
return [x + nx * o + amp, y + ny * o + amp];
|
|
577
|
-
});
|
|
578
|
-
void W0; void H0;
|
|
579
|
-
// closed Catmull-Rom through the points, as cubic Béziers
|
|
580
|
-
let d = `M${P[0][0].toFixed(2)},${P[0][1].toFixed(2)}`;
|
|
581
|
-
for (let i = 0; i < n; i++) {
|
|
582
|
-
const p0 = P[(i - 1 + n) % n], p1 = P[i], p2 = P[(i + 1) % n], p3 = P[(i + 2) % n];
|
|
583
|
-
const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
|
|
584
|
-
const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
|
|
585
|
-
d += `C${c1[0].toFixed(2)},${c1[1].toFixed(2)} ${c2[0].toFixed(2)},${c2[1].toFixed(2)} ${p2[0].toFixed(2)},${p2[1].toFixed(2)}`;
|
|
586
|
-
}
|
|
587
|
-
return d + 'Z';
|
|
588
|
-
}
|
|
330
|
+
export * from './core.js';
|
|
331
|
+
import { clamp, lerp, invlerp, progress, ease, keys, mix, mixColor, spring, rng, noise, split } from './core.js';
|
|
589
332
|
|
|
590
333
|
/* ------------------------------------------------------------------ */
|
|
591
334
|
/* Live shaders (web pages, not scenes) */
|
package/runtime/kit.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// paper-motion/kit: the small helpers the moves share (geometry, springs, effect layers, live copies).
|
|
2
|
+
// A move copied into a project with `pamo add` imports them from here, so the copy stays short and editable.
|
|
3
|
+
import { ease, clamp, lerp, progress, spring, split } from './core.js';
|
|
4
|
+
|
|
5
|
+
export const E = (p, curve = ease.inOutCubic) => curve(clamp(p));
|
|
6
|
+
|
|
7
|
+
export const words = (el) => {
|
|
8
|
+
if (!el._pmWords) {
|
|
9
|
+
// keep <br> line breaks: split each text run into words, leave the breaks where they were
|
|
10
|
+
const parts = [...el.childNodes].flatMap((n) => (n.nodeName === 'BR' ? ['\n'] : (n.textContent ?? '').split(/(\s+)/)));
|
|
11
|
+
el.textContent = '';
|
|
12
|
+
el._pmWords = parts.map((w) => {
|
|
13
|
+
if (w === '\n') { el.append(document.createElement('br')); return null; }
|
|
14
|
+
if (!w) return null;
|
|
15
|
+
if (/^\s+$/.test(w)) { el.append(w); return null; }
|
|
16
|
+
const outer = Object.assign(document.createElement('span'), { style: 'display:inline-block;overflow:hidden;vertical-align:bottom;padding-bottom:0.08em;margin-bottom:-0.08em' });
|
|
17
|
+
const inner = Object.assign(document.createElement('span'), { textContent: w, style: 'display:inline-block' });
|
|
18
|
+
outer.append(inner); el.append(outer);
|
|
19
|
+
return inner;
|
|
20
|
+
}).filter(Boolean);
|
|
21
|
+
}
|
|
22
|
+
return el._pmWords;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const chars = (el) => (el._labChars ??= split(el, 'chars'));
|
|
26
|
+
|
|
27
|
+
export const hash = (a, b) => { const x = Math.sin(a * 12.9898 + b * 78.233) * 43758.5453; return x - Math.floor(x); };
|
|
28
|
+
|
|
29
|
+
// integer edges: half-pixel edges of neighbouring rects leave faint seams
|
|
30
|
+
export const rects = (list) => list.map(([x, y, w, h]) => { const x0 = Math.floor(x), y0 = Math.floor(y), x1 = Math.ceil(x + w), y1 = Math.ceil(y + h); return `M${x0} ${y0}H${x1}V${y1}H${x0}Z`; }).join('');
|
|
31
|
+
|
|
32
|
+
export const clipTo = (el, d) => { const v = d ? `path('${d}')` : 'inset(100%)'; el.style.clipPath = v; el.style.webkitClipPath = v; };
|
|
33
|
+
|
|
34
|
+
export const size = (a, b) => [a.offsetWidth || b.offsetWidth, a.offsetHeight || b.offsetHeight];
|
|
35
|
+
|
|
36
|
+
/** A horizontal-only blur (an SVG filter shared per id), for motion blur on fast pushes. */
|
|
37
|
+
export function hblur(id, std) {
|
|
38
|
+
let f = document.getElementById(id);
|
|
39
|
+
if (!f) {
|
|
40
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
41
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
42
|
+
svg.setAttribute('width', 0); svg.setAttribute('height', 0); svg.style.position = 'absolute';
|
|
43
|
+
svg.innerHTML = `<filter id="${id}" x="-30%" y="0" width="160%" height="100%" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="0 0"/></filter>`;
|
|
44
|
+
document.body.append(svg);
|
|
45
|
+
f = svg.querySelector('filter');
|
|
46
|
+
}
|
|
47
|
+
f.firstElementChild.setAttribute('stdDeviation', `${std} 0`);
|
|
48
|
+
return `url(#${id})`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const dims = (el) => [el.offsetWidth, el.offsetHeight];
|
|
52
|
+
|
|
53
|
+
/** Children that are content (not effect layers added by these moves). */
|
|
54
|
+
export const items = (el) => [...el.children].filter((c) => !c.classList.contains('pm-fx'));
|
|
55
|
+
|
|
56
|
+
export const show = (a, b, p) => { a.style.opacity = p >= 1 ? 0 : 1; b.style.opacity = p > 0 ? 1 : 0; };
|
|
57
|
+
|
|
58
|
+
/** A spring released at progress `start`, in the transition's seconds. */
|
|
59
|
+
export const sp = (p, start, duration, o = {}) => spring((p - start) * duration, { stiffness: 200, damping: 18, ...o });
|
|
60
|
+
|
|
61
|
+
/** Eases any value onto 1 over the last stretch, so a spring lands exactly at p = 1. */
|
|
62
|
+
export const settle = (v, p, from = 0.85) => lerp(v, 1, progress(p, from, 1));
|
|
63
|
+
|
|
64
|
+
export const blur = (px) => (px > 0.3 ? `blur(${px}px)` : 'none');
|
|
65
|
+
|
|
66
|
+
export const orig = (el, prop) => ((el._pmOrig ??= {})[prop] ??= { inline: el.style[prop], computed: getComputedStyle(el)[prop] });
|
|
67
|
+
|
|
68
|
+
export const rgba = (c) => { const v = (c.match(/[\d.]+/g) ?? ['0', '0', '0', '0']).map(Number); return [v[0], v[1], v[2], v[3] ?? 1]; };
|
|
69
|
+
|
|
70
|
+
export const mixRgba = (c1, c2, q) => { const x = rgba(c1), y = rgba(c2); return `rgba(${[0, 1, 2].map((i) => Math.round(lerp(x[i], y[i], q))).join(',')},${lerp(x[3], y[3], q)})`; };
|
|
71
|
+
|
|
72
|
+
/** Layout rect of `el` relative to `root` (transforms ignored, so it is stable while things move). */
|
|
73
|
+
export function rectIn(el, root) { let x = 0, y = 0, n = el; while (n && n !== root) { x += n.offsetLeft; y += n.offsetTop; n = n.offsetParent; } return { x, y, w: el.offsetWidth, h: el.offsetHeight }; }
|
|
74
|
+
|
|
75
|
+
export const lerpRect = (r1, r2, q) => ({ x: lerp(r1.x, r2.x, q), y: lerp(r1.y, r2.y, q), w: lerp(r1.w, r2.w, q), h: lerp(r1.h, r2.h, q) });
|
|
76
|
+
|
|
77
|
+
/** An effect layer owned by `host`, made once and restyled every frame. */
|
|
78
|
+
export function layer(host, key, css) {
|
|
79
|
+
const k = `_pmFx_${key}`;
|
|
80
|
+
let d = host[k];
|
|
81
|
+
if (!d) { d = host[k] = document.createElement('div'); d.className = 'pm-fx'; host.append(d); }
|
|
82
|
+
d.style.cssText = `position:absolute;pointer-events:none;${css}`;
|
|
83
|
+
return d;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const hide = (host, ...keys) => { for (const k of keys) { const d = host[`_pmFx_${k}`]; if (d) d.style.display = 'none'; } };
|
|
87
|
+
|
|
88
|
+
export const resetAll = (...els) => { for (const e of els) for (const k of ['transform', 'opacity', 'filter', 'clipPath', 'transformOrigin']) e.style[k] = ''; };
|
|
89
|
+
|
|
90
|
+
/** Live copies of `el` drawn into `host` (for moves that cut a component into pieces). */
|
|
91
|
+
export function copy(el) {
|
|
92
|
+
const c = el.cloneNode(true);
|
|
93
|
+
c.querySelectorAll('.pm-fx').forEach((x) => x.remove());
|
|
94
|
+
Object.assign(c.style, { position: 'absolute', left: '0', top: '0', right: 'auto', bottom: 'auto', width: `${el.offsetWidth}px`, height: `${el.offsetHeight}px`, margin: '0', transform: 'none', opacity: '1', clipPath: 'none', filter: 'none', zIndex: 'auto' });
|
|
95
|
+
return c;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const partsOf = (el) => new Map([...el.querySelectorAll('[data-part]')].map((e) => [e.dataset.part, e]));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { ease, progress } from '../core.js';
|
|
2
|
+
|
|
3
|
+
/** A solid bar grows over the line, then pulls away to the other side, leaving the text behind it. */
|
|
4
|
+
export function barReveal(el, t, { start = 0, duration = 0.8, color = '#fff', curve = ease.inOutQuart } = {}) {
|
|
5
|
+
if (!el._labBar) {
|
|
6
|
+
const content = document.createElement('span');
|
|
7
|
+
content.append(...el.childNodes);
|
|
8
|
+
const bar = document.createElement('span');
|
|
9
|
+
Object.assign(bar.style, { position: 'absolute', inset: '0.06em -0.04em', background: color });
|
|
10
|
+
el.style.position = 'relative'; el.style.display = 'inline-block';
|
|
11
|
+
el.append(content, bar);
|
|
12
|
+
el._labBar = { content, bar };
|
|
13
|
+
}
|
|
14
|
+
const { content, bar } = el._labBar, half = start + duration / 2;
|
|
15
|
+
const a = curve(progress(t, start, half)), b = curve(progress(t, half, start + duration));
|
|
16
|
+
bar.style.transformOrigin = t < half ? '0 50%' : '100% 50%';
|
|
17
|
+
bar.style.transform = `scaleX(${t < half ? a : 1 - b})`;
|
|
18
|
+
content.style.opacity = t >= half ? 1 : 0;
|
|
19
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ease, progress } from '../core.js';
|
|
2
|
+
|
|
3
|
+
/** Text comes into focus from a blur. */
|
|
4
|
+
export function blurIn(el, t, { start = 0, duration = 0.8, blur = 14, curve = ease.outCubic } = {}) {
|
|
5
|
+
const q = curve(progress(t, start, start + duration));
|
|
6
|
+
el.style.filter = `blur(${(1 - q) * blur}px)`; el.style.opacity = q;
|
|
7
|
+
}
|