@ansh-ui/core 0.2.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/dist/behaviours.cjs +64 -0
- package/dist/behaviours.cjs.map +1 -0
- package/dist/behaviours.d.cts +3 -0
- package/dist/behaviours.d.ts +3 -0
- package/dist/behaviours.js +7 -0
- package/dist/behaviours.js.map +1 -0
- package/dist/chunk-23GSSIMK.cjs +439 -0
- package/dist/chunk-23GSSIMK.cjs.map +1 -0
- package/dist/chunk-56NNBIAO.js +2446 -0
- package/dist/chunk-56NNBIAO.js.map +1 -0
- package/dist/chunk-6PFYGJWD.cjs +136 -0
- package/dist/chunk-6PFYGJWD.cjs.map +1 -0
- package/dist/chunk-7MEIXWPH.js +313 -0
- package/dist/chunk-7MEIXWPH.js.map +1 -0
- package/dist/chunk-7MYTSAPR.js +83 -0
- package/dist/chunk-7MYTSAPR.js.map +1 -0
- package/dist/chunk-BOQQHICI.js +1036 -0
- package/dist/chunk-BOQQHICI.js.map +1 -0
- package/dist/chunk-C6XPQO2O.js +1170 -0
- package/dist/chunk-C6XPQO2O.js.map +1 -0
- package/dist/chunk-DXJ63QAR.cjs +723 -0
- package/dist/chunk-DXJ63QAR.cjs.map +1 -0
- package/dist/chunk-FAVIEION.js +705 -0
- package/dist/chunk-FAVIEION.js.map +1 -0
- package/dist/chunk-FNR35HTL.cjs +107 -0
- package/dist/chunk-FNR35HTL.cjs.map +1 -0
- package/dist/chunk-GHBRGMSO.cjs +91 -0
- package/dist/chunk-GHBRGMSO.cjs.map +1 -0
- package/dist/chunk-GJRIHN76.cjs +359 -0
- package/dist/chunk-GJRIHN76.cjs.map +1 -0
- package/dist/chunk-ILKUL7GD.cjs +115 -0
- package/dist/chunk-ILKUL7GD.cjs.map +1 -0
- package/dist/chunk-KTUBCQC3.js +409 -0
- package/dist/chunk-KTUBCQC3.js.map +1 -0
- package/dist/chunk-KU2C6GEA.js +90 -0
- package/dist/chunk-KU2C6GEA.js.map +1 -0
- package/dist/chunk-LGA3N3YJ.cjs +413 -0
- package/dist/chunk-LGA3N3YJ.cjs.map +1 -0
- package/dist/chunk-MMO7KO53.cjs +1060 -0
- package/dist/chunk-MMO7KO53.cjs.map +1 -0
- package/dist/chunk-NSXKCJJ7.js +109 -0
- package/dist/chunk-NSXKCJJ7.js.map +1 -0
- package/dist/chunk-QALZKHWH.js +387 -0
- package/dist/chunk-QALZKHWH.js.map +1 -0
- package/dist/chunk-R7UJ7MWP.cjs +2479 -0
- package/dist/chunk-R7UJ7MWP.cjs.map +1 -0
- package/dist/chunk-UJBXY6QC.cjs +1186 -0
- package/dist/chunk-UJBXY6QC.cjs.map +1 -0
- package/dist/chunk-YFQXRC5R.js +99 -0
- package/dist/chunk-YFQXRC5R.js.map +1 -0
- package/dist/color.cjs +257 -0
- package/dist/color.cjs.map +1 -0
- package/dist/color.d.cts +303 -0
- package/dist/color.d.ts +303 -0
- package/dist/color.js +8 -0
- package/dist/color.js.map +1 -0
- package/dist/easing.cjs +189 -0
- package/dist/easing.cjs.map +1 -0
- package/dist/easing.d.cts +134 -0
- package/dist/easing.d.ts +134 -0
- package/dist/easing.js +4 -0
- package/dist/easing.js.map +1 -0
- package/dist/effects.cjs +99 -0
- package/dist/effects.cjs.map +1 -0
- package/dist/effects.d.cts +5 -0
- package/dist/effects.d.ts +5 -0
- package/dist/effects.js +10 -0
- package/dist/effects.js.map +1 -0
- package/dist/index-CCdpefMp.d.ts +967 -0
- package/dist/index-CQEEPu-D.d.ts +435 -0
- package/dist/index-DHOfsEIn.d.cts +967 -0
- package/dist/index-Dl8GCsya.d.cts +435 -0
- package/dist/index.cjs +2206 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1122 -0
- package/dist/index.d.ts +1122 -0
- package/dist/index.js +1182 -0
- package/dist/index.js.map +1 -0
- package/dist/math.cjs +238 -0
- package/dist/math.cjs.map +1 -0
- package/dist/math.d.cts +120 -0
- package/dist/math.d.ts +120 -0
- package/dist/math.js +5 -0
- package/dist/math.js.map +1 -0
- package/dist/noise.cjs +78 -0
- package/dist/noise.cjs.map +1 -0
- package/dist/noise.d.cts +190 -0
- package/dist/noise.d.ts +190 -0
- package/dist/noise.js +5 -0
- package/dist/noise.js.map +1 -0
- package/dist/palette-6HkVltYU.d.cts +219 -0
- package/dist/palette-6HkVltYU.d.ts +219 -0
- package/dist/random--4mSc5Lp.d.cts +75 -0
- package/dist/random--4mSc5Lp.d.ts +75 -0
- package/dist/reduced-motion-D0LUgZle.d.cts +120 -0
- package/dist/reduced-motion-D0LUgZle.d.ts +120 -0
- package/dist/types-B1FLLf3-.d.cts +21 -0
- package/dist/types-B1FLLf3-.d.ts +21 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1036 @@
|
|
|
1
|
+
import { clamp, percentileSorted, damp } from './chunk-KU2C6GEA.js';
|
|
2
|
+
|
|
3
|
+
// src/kernel/types.ts
|
|
4
|
+
var FRAME_PHASES = ["read", "compute", "write", "post"];
|
|
5
|
+
|
|
6
|
+
// src/kernel/clock.ts
|
|
7
|
+
var Clock = class {
|
|
8
|
+
maxDelta;
|
|
9
|
+
scale;
|
|
10
|
+
startTimestamp = 0;
|
|
11
|
+
lastTimestamp = 0;
|
|
12
|
+
accumulatedTime = 0;
|
|
13
|
+
frameIndex = 0;
|
|
14
|
+
running = false;
|
|
15
|
+
/** Leftover time for the fixed-step accumulator. */
|
|
16
|
+
fixedRemainder = 0;
|
|
17
|
+
constructor(options = {}) {
|
|
18
|
+
this.maxDelta = Math.max(1e-4, options.maxDelta ?? 1 / 15);
|
|
19
|
+
this.scale = options.timeScale ?? 1;
|
|
20
|
+
}
|
|
21
|
+
get timeScale() {
|
|
22
|
+
return this.scale;
|
|
23
|
+
}
|
|
24
|
+
set timeScale(value) {
|
|
25
|
+
this.scale = Math.max(0, value);
|
|
26
|
+
}
|
|
27
|
+
get isRunning() {
|
|
28
|
+
return this.running;
|
|
29
|
+
}
|
|
30
|
+
get elapsed() {
|
|
31
|
+
return this.accumulatedTime;
|
|
32
|
+
}
|
|
33
|
+
start(timestamp) {
|
|
34
|
+
this.startTimestamp = timestamp;
|
|
35
|
+
this.lastTimestamp = timestamp;
|
|
36
|
+
this.running = true;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resume after a pause.
|
|
40
|
+
*
|
|
41
|
+
* Only `lastTimestamp` is reset, so the delta for the resumed frame is small rather than
|
|
42
|
+
* "however long the pause lasted". `accumulatedTime` continues from where it stopped, which
|
|
43
|
+
* is what makes pause/resume invisible to animations mid-flight.
|
|
44
|
+
*/
|
|
45
|
+
resume(timestamp) {
|
|
46
|
+
this.lastTimestamp = timestamp;
|
|
47
|
+
this.running = true;
|
|
48
|
+
}
|
|
49
|
+
pause() {
|
|
50
|
+
this.running = false;
|
|
51
|
+
}
|
|
52
|
+
reset() {
|
|
53
|
+
this.startTimestamp = 0;
|
|
54
|
+
this.lastTimestamp = 0;
|
|
55
|
+
this.accumulatedTime = 0;
|
|
56
|
+
this.frameIndex = 0;
|
|
57
|
+
this.fixedRemainder = 0;
|
|
58
|
+
this.running = false;
|
|
59
|
+
}
|
|
60
|
+
/** Advance the clock and produce this frame's timing. */
|
|
61
|
+
tick(timestamp) {
|
|
62
|
+
const rawDelta = Math.max(0, (timestamp - this.lastTimestamp) / 1e3);
|
|
63
|
+
this.lastTimestamp = timestamp;
|
|
64
|
+
const delta = clamp(rawDelta, 0, this.maxDelta) * this.scale;
|
|
65
|
+
this.accumulatedTime += delta;
|
|
66
|
+
this.frameIndex++;
|
|
67
|
+
return {
|
|
68
|
+
time: this.accumulatedTime,
|
|
69
|
+
delta,
|
|
70
|
+
rawDelta,
|
|
71
|
+
frame: this.frameIndex,
|
|
72
|
+
timestamp
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Number of fixed-size physics steps to run for a variable-length frame.
|
|
77
|
+
*
|
|
78
|
+
* Verlet and spring-constraint integrators are only stable at a fixed step; running them
|
|
79
|
+
* with a variable delta makes stiffness frame-rate dependent, so a 144 Hz monitor gets
|
|
80
|
+
* different physics from a 60 Hz one. This accumulates the leftover and reports whole steps.
|
|
81
|
+
*
|
|
82
|
+
* `maxSteps` bounds the work when a long frame has queued up many steps — better to fall
|
|
83
|
+
* slightly behind real time than to spiral into an ever-longer catch-up.
|
|
84
|
+
*/
|
|
85
|
+
fixedSteps(delta, step, maxSteps = 4) {
|
|
86
|
+
this.fixedRemainder += delta;
|
|
87
|
+
const steps = Math.min(maxSteps, Math.floor(this.fixedRemainder / step));
|
|
88
|
+
this.fixedRemainder -= steps * step;
|
|
89
|
+
if (this.fixedRemainder > step * maxSteps) this.fixedRemainder = 0;
|
|
90
|
+
return steps;
|
|
91
|
+
}
|
|
92
|
+
/** Fraction of the way into the next fixed step, for interpolating rendered positions. */
|
|
93
|
+
get fixedAlpha() {
|
|
94
|
+
return this.fixedRemainder;
|
|
95
|
+
}
|
|
96
|
+
get totalWallTime() {
|
|
97
|
+
return (this.lastTimestamp - this.startTimestamp) / 1e3;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// src/kernel/budget.ts
|
|
102
|
+
var FrameBudget = class {
|
|
103
|
+
share;
|
|
104
|
+
windowSize;
|
|
105
|
+
degradeRate;
|
|
106
|
+
recoverRate;
|
|
107
|
+
minQuality;
|
|
108
|
+
targetFrameMs;
|
|
109
|
+
explicitTarget;
|
|
110
|
+
costs;
|
|
111
|
+
cursor = 0;
|
|
112
|
+
filled = 0;
|
|
113
|
+
scratch = [];
|
|
114
|
+
qualityValue = 1;
|
|
115
|
+
fpsValue = 60;
|
|
116
|
+
p50Value = 0;
|
|
117
|
+
p95Value = 0;
|
|
118
|
+
/** Frame intervals used to detect the display refresh rate. */
|
|
119
|
+
intervalSamples = [];
|
|
120
|
+
refreshDetected = false;
|
|
121
|
+
constructor(options = {}) {
|
|
122
|
+
this.share = clamp(options.share ?? 0.5, 0.1, 1);
|
|
123
|
+
this.windowSize = Math.max(15, Math.floor(options.window ?? 90));
|
|
124
|
+
this.degradeRate = Math.max(0, options.degradeRate ?? 0.6);
|
|
125
|
+
this.recoverRate = Math.max(0, options.recoverRate ?? 0.12);
|
|
126
|
+
this.minQuality = clamp(options.minQuality ?? 0.25, 0.05, 1);
|
|
127
|
+
this.explicitTarget = options.targetFrameMs !== void 0;
|
|
128
|
+
this.targetFrameMs = options.targetFrameMs ?? 1e3 / 60;
|
|
129
|
+
this.costs = new Float32Array(this.windowSize);
|
|
130
|
+
}
|
|
131
|
+
get quality() {
|
|
132
|
+
return this.qualityValue;
|
|
133
|
+
}
|
|
134
|
+
get budgetMs() {
|
|
135
|
+
return this.targetFrameMs * this.share;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Record one frame.
|
|
139
|
+
*
|
|
140
|
+
* @param costMs Milliseconds spent inside Ansh callbacks this frame.
|
|
141
|
+
* @param rawDelta Unscaled seconds since the previous frame, for FPS and refresh detection.
|
|
142
|
+
*/
|
|
143
|
+
record(costMs, rawDelta) {
|
|
144
|
+
if (rawDelta > 0) {
|
|
145
|
+
const instantFps = 1 / rawDelta;
|
|
146
|
+
this.fpsValue += (instantFps - this.fpsValue) * 0.1;
|
|
147
|
+
if (!this.explicitTarget) this.detectRefreshRate(rawDelta * 1e3);
|
|
148
|
+
}
|
|
149
|
+
this.costs[this.cursor] = costMs;
|
|
150
|
+
this.cursor = (this.cursor + 1) % this.windowSize;
|
|
151
|
+
if (this.filled < this.windowSize) this.filled++;
|
|
152
|
+
this.recomputePercentiles();
|
|
153
|
+
this.adaptQuality(rawDelta);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Infer the display refresh rate from observed frame intervals.
|
|
157
|
+
*
|
|
158
|
+
* Assuming 60 Hz wastes half the available budget on a 120 Hz display and overspends on a
|
|
159
|
+
* 30 Hz one. We take the median of the first 40 intervals (median, not mean, so a couple of
|
|
160
|
+
* long startup frames don't skew it) and snap to the nearest common rate.
|
|
161
|
+
*/
|
|
162
|
+
detectRefreshRate(intervalMs) {
|
|
163
|
+
if (this.refreshDetected) return;
|
|
164
|
+
if (intervalMs < 1 || intervalMs > 50) return;
|
|
165
|
+
this.intervalSamples.push(intervalMs);
|
|
166
|
+
if (this.intervalSamples.length < 40) return;
|
|
167
|
+
const sorted = this.intervalSamples.slice().sort((a, b) => a - b);
|
|
168
|
+
const median = percentileSorted(sorted, 0.5);
|
|
169
|
+
const hz = 1e3 / median;
|
|
170
|
+
const COMMON_RATES = [30, 48, 60, 72, 90, 120, 144, 165, 240];
|
|
171
|
+
let nearest = 60;
|
|
172
|
+
let bestDistance = Infinity;
|
|
173
|
+
for (const rate of COMMON_RATES) {
|
|
174
|
+
const distance = Math.abs(rate - hz);
|
|
175
|
+
if (distance < bestDistance) {
|
|
176
|
+
bestDistance = distance;
|
|
177
|
+
nearest = rate;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
this.targetFrameMs = 1e3 / nearest;
|
|
181
|
+
this.refreshDetected = true;
|
|
182
|
+
this.intervalSamples = [];
|
|
183
|
+
}
|
|
184
|
+
recomputePercentiles() {
|
|
185
|
+
const n = this.filled;
|
|
186
|
+
if (n === 0) return;
|
|
187
|
+
if (this.scratch.length !== n) this.scratch = new Array(n);
|
|
188
|
+
for (let i = 0; i < n; i++) this.scratch[i] = this.costs[i];
|
|
189
|
+
this.scratch.sort(ascending);
|
|
190
|
+
this.p50Value = percentileSorted(this.scratch, 0.5);
|
|
191
|
+
this.p95Value = percentileSorted(this.scratch, 0.95);
|
|
192
|
+
}
|
|
193
|
+
adaptQuality(rawDelta) {
|
|
194
|
+
if (this.filled < 30) return;
|
|
195
|
+
const budget = this.budgetMs;
|
|
196
|
+
const dt = clamp(rawDelta, 0, 0.1);
|
|
197
|
+
if (this.p95Value > budget) {
|
|
198
|
+
const severity = clamp(this.p95Value / budget - 1, 0, 2);
|
|
199
|
+
this.qualityValue -= this.degradeRate * severity * dt;
|
|
200
|
+
} else if (this.p95Value < budget * 0.6) {
|
|
201
|
+
this.qualityValue += this.recoverRate * dt;
|
|
202
|
+
}
|
|
203
|
+
this.qualityValue = clamp(this.qualityValue, this.minQuality, 1);
|
|
204
|
+
}
|
|
205
|
+
report() {
|
|
206
|
+
return {
|
|
207
|
+
quality: this.qualityValue,
|
|
208
|
+
fps: this.fpsValue,
|
|
209
|
+
p50: this.p50Value,
|
|
210
|
+
p95: this.p95Value,
|
|
211
|
+
budgetMs: this.budgetMs,
|
|
212
|
+
degraded: this.qualityValue < 0.999
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/** Force quality to a fixed value, disabling adaptation. Used by `performance="high"`. */
|
|
216
|
+
lockQuality(value) {
|
|
217
|
+
this.qualityValue = clamp(value, this.minQuality, 1);
|
|
218
|
+
this.filled = 0;
|
|
219
|
+
this.cursor = 0;
|
|
220
|
+
this.costs.fill(0);
|
|
221
|
+
}
|
|
222
|
+
reset() {
|
|
223
|
+
this.costs.fill(0);
|
|
224
|
+
this.cursor = 0;
|
|
225
|
+
this.filled = 0;
|
|
226
|
+
this.qualityValue = 1;
|
|
227
|
+
this.p50Value = 0;
|
|
228
|
+
this.p95Value = 0;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
function ascending(a, b) {
|
|
232
|
+
return a - b;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// src/kernel/ticker.ts
|
|
236
|
+
var MAX_CONSECUTIVE_ERRORS = 3;
|
|
237
|
+
var Ticker = class {
|
|
238
|
+
clock;
|
|
239
|
+
budget;
|
|
240
|
+
phases = /* @__PURE__ */ new Map();
|
|
241
|
+
autoStart;
|
|
242
|
+
schedule;
|
|
243
|
+
cancel;
|
|
244
|
+
readTime;
|
|
245
|
+
handle = null;
|
|
246
|
+
nextId = 1;
|
|
247
|
+
paused = false;
|
|
248
|
+
pauseReasons = /* @__PURE__ */ new Set();
|
|
249
|
+
disposed = false;
|
|
250
|
+
/**
|
|
251
|
+
* Phase arrays are rebuilt lazily rather than re-sorted on every add/remove: components
|
|
252
|
+
* mount in bursts, and sorting once before the next frame is cheaper than sorting per call.
|
|
253
|
+
*/
|
|
254
|
+
dirtyPhases = /* @__PURE__ */ new Set();
|
|
255
|
+
/** Callbacks queued to run exactly once on the next frame. */
|
|
256
|
+
onceQueue = [];
|
|
257
|
+
listeners = /* @__PURE__ */ new Set();
|
|
258
|
+
state = {
|
|
259
|
+
time: 0,
|
|
260
|
+
delta: 0,
|
|
261
|
+
rawDelta: 0,
|
|
262
|
+
frame: 0,
|
|
263
|
+
fps: 60,
|
|
264
|
+
quality: 1,
|
|
265
|
+
timestamp: 0
|
|
266
|
+
};
|
|
267
|
+
constructor(options = {}) {
|
|
268
|
+
this.clock = new Clock(options);
|
|
269
|
+
this.budget = new FrameBudget(options.budget);
|
|
270
|
+
this.autoStart = options.autoStart ?? true;
|
|
271
|
+
const raf = options.scheduler ?? (typeof requestAnimationFrame === "function" ? requestAnimationFrame.bind(globalThis) : (cb) => setTimeout(() => cb(now()), 16));
|
|
272
|
+
const caf = options.canceller ?? (typeof cancelAnimationFrame === "function" ? cancelAnimationFrame.bind(globalThis) : (h) => clearTimeout(h));
|
|
273
|
+
this.schedule = raf;
|
|
274
|
+
this.cancel = caf;
|
|
275
|
+
this.readTime = options.now ?? now;
|
|
276
|
+
for (const phase of FRAME_PHASES) this.phases.set(phase, []);
|
|
277
|
+
}
|
|
278
|
+
// -------------------------------------------------------------------------
|
|
279
|
+
// Subscription
|
|
280
|
+
// -------------------------------------------------------------------------
|
|
281
|
+
/**
|
|
282
|
+
* Register a per-frame callback.
|
|
283
|
+
*
|
|
284
|
+
* @returns An idempotent unsubscribe function. Always call it on unmount — the leak harness
|
|
285
|
+
* in the test suite fails the build if a component forgets.
|
|
286
|
+
*/
|
|
287
|
+
add(callback, options = {}) {
|
|
288
|
+
if (this.disposed) return noop;
|
|
289
|
+
const phase = options.phase ?? "write";
|
|
290
|
+
const subscriber = {
|
|
291
|
+
id: this.nextId++,
|
|
292
|
+
callback,
|
|
293
|
+
priority: options.priority ?? 0,
|
|
294
|
+
name: options.name ?? "anonymous",
|
|
295
|
+
errorCount: 0
|
|
296
|
+
};
|
|
297
|
+
const list = this.phases.get(phase);
|
|
298
|
+
if (!list) return noop;
|
|
299
|
+
list.push(subscriber);
|
|
300
|
+
this.dirtyPhases.add(phase);
|
|
301
|
+
if (this.autoStart) this.start();
|
|
302
|
+
let removed = false;
|
|
303
|
+
return () => {
|
|
304
|
+
if (removed) return;
|
|
305
|
+
removed = true;
|
|
306
|
+
const index = list.indexOf(subscriber);
|
|
307
|
+
if (index >= 0) list.splice(index, 1);
|
|
308
|
+
if (this.subscriberCount === 0) this.stop();
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** Run a callback on the next frame only. Useful for deferring a DOM read past a write. */
|
|
312
|
+
once(callback) {
|
|
313
|
+
if (this.disposed) return;
|
|
314
|
+
this.onceQueue.push(callback);
|
|
315
|
+
if (this.autoStart) this.start();
|
|
316
|
+
}
|
|
317
|
+
/** Resolve on the next frame. `await ticker.nextFrame()` reads well in setup code. */
|
|
318
|
+
nextFrame() {
|
|
319
|
+
return new Promise((resolve) => this.once(resolve));
|
|
320
|
+
}
|
|
321
|
+
get subscriberCount() {
|
|
322
|
+
let total = 0;
|
|
323
|
+
for (const list of this.phases.values()) total += list.length;
|
|
324
|
+
return total;
|
|
325
|
+
}
|
|
326
|
+
/** Per-phase subscriber counts. Consumed by the dev inspector and the leak harness. */
|
|
327
|
+
inspect() {
|
|
328
|
+
const out = {};
|
|
329
|
+
for (const phase of FRAME_PHASES) {
|
|
330
|
+
const list = this.phases.get(phase) ?? [];
|
|
331
|
+
out[phase] = { count: list.length, names: list.map((s) => s.name) };
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
// -------------------------------------------------------------------------
|
|
336
|
+
// Lifecycle
|
|
337
|
+
// -------------------------------------------------------------------------
|
|
338
|
+
start() {
|
|
339
|
+
if (this.disposed || this.handle !== null) return;
|
|
340
|
+
const timestamp = this.readTime();
|
|
341
|
+
if (this.clock.isRunning) this.clock.resume(timestamp);
|
|
342
|
+
else this.clock.start(timestamp);
|
|
343
|
+
this.handle = this.schedule(this.loop);
|
|
344
|
+
}
|
|
345
|
+
stop() {
|
|
346
|
+
if (this.handle === null) return;
|
|
347
|
+
this.cancel(this.handle);
|
|
348
|
+
this.handle = null;
|
|
349
|
+
this.clock.pause();
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Pause the ticker, tagged with a reason.
|
|
353
|
+
*
|
|
354
|
+
* Reasons are reference-counted by name so that independent systems can pause without
|
|
355
|
+
* fighting: if the tab is hidden *and* the user prefers reduced motion, both hold a pause,
|
|
356
|
+
* and resuming one doesn't restart animation while the other still applies.
|
|
357
|
+
*/
|
|
358
|
+
pause(reason = "manual") {
|
|
359
|
+
this.pauseReasons.add(reason);
|
|
360
|
+
this.paused = true;
|
|
361
|
+
this.stop();
|
|
362
|
+
}
|
|
363
|
+
resume(reason = "manual") {
|
|
364
|
+
this.pauseReasons.delete(reason);
|
|
365
|
+
if (this.pauseReasons.size > 0) return;
|
|
366
|
+
this.paused = false;
|
|
367
|
+
if (this.subscriberCount > 0 || this.onceQueue.length > 0) this.start();
|
|
368
|
+
}
|
|
369
|
+
get isPaused() {
|
|
370
|
+
return this.paused;
|
|
371
|
+
}
|
|
372
|
+
get pausedBecause() {
|
|
373
|
+
return [...this.pauseReasons];
|
|
374
|
+
}
|
|
375
|
+
get isRunning() {
|
|
376
|
+
return this.handle !== null;
|
|
377
|
+
}
|
|
378
|
+
get timeScale() {
|
|
379
|
+
return this.clock.timeScale;
|
|
380
|
+
}
|
|
381
|
+
set timeScale(value) {
|
|
382
|
+
this.clock.timeScale = value;
|
|
383
|
+
}
|
|
384
|
+
get time() {
|
|
385
|
+
return this.clock.elapsed;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Advance one frame manually.
|
|
389
|
+
*
|
|
390
|
+
* The escape hatch that makes the whole engine testable: a test can drive 100 deterministic
|
|
391
|
+
* frames without waiting on a real display, and a screenshot harness can seek to an exact
|
|
392
|
+
* frame index.
|
|
393
|
+
*/
|
|
394
|
+
step(deltaMs = 1e3 / 60) {
|
|
395
|
+
const timestamp = this.state.timestamp + deltaMs;
|
|
396
|
+
if (!this.clock.isRunning) this.clock.start(timestamp - deltaMs);
|
|
397
|
+
this.runFrame(timestamp);
|
|
398
|
+
return this.state;
|
|
399
|
+
}
|
|
400
|
+
/** Release everything. The ticker cannot be restarted afterwards. */
|
|
401
|
+
dispose() {
|
|
402
|
+
this.stop();
|
|
403
|
+
this.disposed = true;
|
|
404
|
+
for (const list of this.phases.values()) list.length = 0;
|
|
405
|
+
this.onceQueue.length = 0;
|
|
406
|
+
this.listeners.clear();
|
|
407
|
+
}
|
|
408
|
+
// -------------------------------------------------------------------------
|
|
409
|
+
// Budget
|
|
410
|
+
// -------------------------------------------------------------------------
|
|
411
|
+
get quality() {
|
|
412
|
+
return this.budget.quality;
|
|
413
|
+
}
|
|
414
|
+
budgetReport() {
|
|
415
|
+
return this.budget.report();
|
|
416
|
+
}
|
|
417
|
+
/** Disable adaptation and pin quality. Backs the `performance="high" | "low"` prop. */
|
|
418
|
+
lockQuality(value) {
|
|
419
|
+
this.budget.lockQuality(value);
|
|
420
|
+
}
|
|
421
|
+
/** Subscribe to per-frame budget reports — powers the dev-mode FPS overlay. */
|
|
422
|
+
onBudget(listener) {
|
|
423
|
+
this.listeners.add(listener);
|
|
424
|
+
return () => this.listeners.delete(listener);
|
|
425
|
+
}
|
|
426
|
+
// -------------------------------------------------------------------------
|
|
427
|
+
// The loop
|
|
428
|
+
// -------------------------------------------------------------------------
|
|
429
|
+
loop = (timestamp) => {
|
|
430
|
+
this.handle = null;
|
|
431
|
+
this.runFrame(timestamp);
|
|
432
|
+
if (!this.paused && !this.disposed && (this.subscriberCount > 0 || this.onceQueue.length > 0)) {
|
|
433
|
+
this.handle = this.schedule(this.loop);
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
runFrame(timestamp) {
|
|
437
|
+
const timing = this.clock.tick(timestamp);
|
|
438
|
+
const state = this.state;
|
|
439
|
+
state.time = timing.time;
|
|
440
|
+
state.delta = timing.delta;
|
|
441
|
+
state.rawDelta = timing.rawDelta;
|
|
442
|
+
state.frame = timing.frame;
|
|
443
|
+
state.timestamp = timing.timestamp;
|
|
444
|
+
state.quality = this.budget.quality;
|
|
445
|
+
state.fps = this.budget.report().fps;
|
|
446
|
+
const startedAt = this.readTime();
|
|
447
|
+
if (this.dirtyPhases.size > 0) this.sortDirtyPhases();
|
|
448
|
+
for (const phase of FRAME_PHASES) {
|
|
449
|
+
const list = this.phases.get(phase);
|
|
450
|
+
if (!list || list.length === 0) continue;
|
|
451
|
+
const snapshot = list.length === 1 ? list : list.slice();
|
|
452
|
+
for (let i = 0; i < snapshot.length; i++) {
|
|
453
|
+
this.invoke(snapshot[i], state, list);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (this.onceQueue.length > 0) {
|
|
457
|
+
const queue = this.onceQueue;
|
|
458
|
+
this.onceQueue = [];
|
|
459
|
+
for (let i = 0; i < queue.length; i++) {
|
|
460
|
+
try {
|
|
461
|
+
queue[i](state);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
reportError(error, "once callback");
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
this.budget.record(this.readTime() - startedAt, timing.rawDelta);
|
|
468
|
+
if (this.listeners.size > 0) {
|
|
469
|
+
const report = this.budget.report();
|
|
470
|
+
for (const listener of this.listeners) listener(report);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Run one subscriber, containing its failures.
|
|
475
|
+
*
|
|
476
|
+
* A throwing callback must not stop every other animation on the page — that turns one
|
|
477
|
+
* component's bug into a whole-page visual failure. We log, and after several consecutive
|
|
478
|
+
* throws we evict the subscriber, because 60 identical stack traces per second is not
|
|
479
|
+
* diagnostics, it's a denial of service on the console.
|
|
480
|
+
*/
|
|
481
|
+
invoke(subscriber, state, list) {
|
|
482
|
+
try {
|
|
483
|
+
subscriber.callback(state);
|
|
484
|
+
if (subscriber.errorCount > 0) subscriber.errorCount = 0;
|
|
485
|
+
} catch (error) {
|
|
486
|
+
subscriber.errorCount++;
|
|
487
|
+
reportError(error, subscriber.name);
|
|
488
|
+
if (subscriber.errorCount >= MAX_CONSECUTIVE_ERRORS) {
|
|
489
|
+
const index = list.indexOf(subscriber);
|
|
490
|
+
if (index >= 0) list.splice(index, 1);
|
|
491
|
+
console.error(
|
|
492
|
+
`[ansh] Removed frame callback "${subscriber.name}" after ${MAX_CONSECUTIVE_ERRORS} consecutive errors. Other animations continue unaffected.`
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
sortDirtyPhases() {
|
|
498
|
+
for (const phase of this.dirtyPhases) {
|
|
499
|
+
const list = this.phases.get(phase);
|
|
500
|
+
if (list) list.sort((a, b) => a.priority - b.priority || a.id - b.id);
|
|
501
|
+
}
|
|
502
|
+
this.dirtyPhases.clear();
|
|
503
|
+
}
|
|
504
|
+
};
|
|
505
|
+
function reportError(error, source) {
|
|
506
|
+
console.error(`[ansh] Error in frame callback "${source}":`, error);
|
|
507
|
+
}
|
|
508
|
+
function noop() {
|
|
509
|
+
}
|
|
510
|
+
function now() {
|
|
511
|
+
return typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
512
|
+
}
|
|
513
|
+
var sharedTicker = null;
|
|
514
|
+
function getTicker() {
|
|
515
|
+
if (sharedTicker === null) sharedTicker = new Ticker();
|
|
516
|
+
return sharedTicker;
|
|
517
|
+
}
|
|
518
|
+
function setTicker(ticker) {
|
|
519
|
+
if (sharedTicker && sharedTicker !== ticker) sharedTicker.dispose();
|
|
520
|
+
sharedTicker = ticker;
|
|
521
|
+
}
|
|
522
|
+
function resetTicker() {
|
|
523
|
+
if (sharedTicker) sharedTicker.dispose();
|
|
524
|
+
sharedTicker = null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// src/a11y/reduced-motion.ts
|
|
528
|
+
var QUERY = "(prefers-reduced-motion: reduce)";
|
|
529
|
+
var override = "auto";
|
|
530
|
+
var mediaQuery = null;
|
|
531
|
+
var systemPreference = false;
|
|
532
|
+
var initialised = false;
|
|
533
|
+
var listeners = /* @__PURE__ */ new Set();
|
|
534
|
+
function initialise() {
|
|
535
|
+
if (initialised || typeof window === "undefined" || !window.matchMedia) return;
|
|
536
|
+
initialised = true;
|
|
537
|
+
mediaQuery = window.matchMedia(QUERY);
|
|
538
|
+
systemPreference = mediaQuery.matches;
|
|
539
|
+
const handler = (event) => {
|
|
540
|
+
systemPreference = event.matches;
|
|
541
|
+
notify();
|
|
542
|
+
};
|
|
543
|
+
if (typeof mediaQuery.addEventListener === "function") {
|
|
544
|
+
mediaQuery.addEventListener("change", handler);
|
|
545
|
+
} else if (typeof mediaQuery.addListener === "function") {
|
|
546
|
+
mediaQuery.addListener(handler);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function notify() {
|
|
550
|
+
const value = prefersReducedMotion();
|
|
551
|
+
for (const listener of listeners) listener(value);
|
|
552
|
+
}
|
|
553
|
+
function prefersReducedMotion() {
|
|
554
|
+
if (override === "always") return true;
|
|
555
|
+
if (override === "never") return false;
|
|
556
|
+
initialise();
|
|
557
|
+
return systemPreference;
|
|
558
|
+
}
|
|
559
|
+
function setReducedMotionMode(mode) {
|
|
560
|
+
if (override === mode) return;
|
|
561
|
+
override = mode;
|
|
562
|
+
notify();
|
|
563
|
+
}
|
|
564
|
+
function getReducedMotionMode() {
|
|
565
|
+
return override;
|
|
566
|
+
}
|
|
567
|
+
function onReducedMotionChange(listener) {
|
|
568
|
+
initialise();
|
|
569
|
+
listeners.add(listener);
|
|
570
|
+
listener(prefersReducedMotion());
|
|
571
|
+
return () => listeners.delete(listener);
|
|
572
|
+
}
|
|
573
|
+
function resolveMotion(strategy = "static", slowFactor = 0.25) {
|
|
574
|
+
if (!prefersReducedMotion() || strategy === "none") {
|
|
575
|
+
return { animate: true, renderOnce: false, timeScale: 1 };
|
|
576
|
+
}
|
|
577
|
+
switch (strategy) {
|
|
578
|
+
case "static":
|
|
579
|
+
return { animate: false, renderOnce: true, timeScale: 0 };
|
|
580
|
+
case "pause":
|
|
581
|
+
return { animate: false, renderOnce: false, timeScale: 0 };
|
|
582
|
+
case "slow":
|
|
583
|
+
return { animate: true, renderOnce: false, timeScale: slowFactor };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
function resetReducedMotion() {
|
|
587
|
+
override = "auto";
|
|
588
|
+
listeners.clear();
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// src/observers/pool.ts
|
|
592
|
+
var intersectionPools = /* @__PURE__ */ new Map();
|
|
593
|
+
function intersectionKey(config) {
|
|
594
|
+
const threshold = config.threshold ?? 0;
|
|
595
|
+
const thresholds = Array.isArray(threshold) ? [...threshold].sort((a, b) => a - b) : [threshold];
|
|
596
|
+
const rootTag = config.root ? rootIdentity(config.root) : "viewport";
|
|
597
|
+
return `${rootTag}|${config.rootMargin ?? "0px"}|${thresholds.join(",")}`;
|
|
598
|
+
}
|
|
599
|
+
var rootTags = /* @__PURE__ */ new WeakMap();
|
|
600
|
+
var rootCounter = 0;
|
|
601
|
+
function rootIdentity(root) {
|
|
602
|
+
let tag = rootTags.get(root);
|
|
603
|
+
if (!tag) {
|
|
604
|
+
tag = `root${++rootCounter}`;
|
|
605
|
+
rootTags.set(root, tag);
|
|
606
|
+
}
|
|
607
|
+
return tag;
|
|
608
|
+
}
|
|
609
|
+
function observeIntersection(element, callback, config = {}) {
|
|
610
|
+
if (typeof IntersectionObserver === "undefined") return () => {
|
|
611
|
+
};
|
|
612
|
+
const key = intersectionKey(config);
|
|
613
|
+
let pool = intersectionPools.get(key);
|
|
614
|
+
if (!pool) {
|
|
615
|
+
const callbacks = /* @__PURE__ */ new WeakMap();
|
|
616
|
+
const observer = new IntersectionObserver(
|
|
617
|
+
(entries) => {
|
|
618
|
+
for (const entry of entries) {
|
|
619
|
+
const set2 = callbacks.get(entry.target);
|
|
620
|
+
if (!set2) continue;
|
|
621
|
+
for (const cb of [...set2]) cb(entry);
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
{
|
|
625
|
+
root: config.root ?? null,
|
|
626
|
+
rootMargin: config.rootMargin ?? "0px",
|
|
627
|
+
threshold: config.threshold ?? 0
|
|
628
|
+
}
|
|
629
|
+
);
|
|
630
|
+
pool = { observer, callbacks, count: 0 };
|
|
631
|
+
intersectionPools.set(key, pool);
|
|
632
|
+
}
|
|
633
|
+
let set = pool.callbacks.get(element);
|
|
634
|
+
if (!set) {
|
|
635
|
+
set = /* @__PURE__ */ new Set();
|
|
636
|
+
pool.callbacks.set(element, set);
|
|
637
|
+
pool.observer.observe(element);
|
|
638
|
+
pool.count++;
|
|
639
|
+
}
|
|
640
|
+
set.add(callback);
|
|
641
|
+
let released = false;
|
|
642
|
+
return () => {
|
|
643
|
+
if (released) return;
|
|
644
|
+
released = true;
|
|
645
|
+
const current = intersectionPools.get(key);
|
|
646
|
+
if (!current) return;
|
|
647
|
+
const callbackSet = current.callbacks.get(element);
|
|
648
|
+
if (!callbackSet) return;
|
|
649
|
+
callbackSet.delete(callback);
|
|
650
|
+
if (callbackSet.size > 0) return;
|
|
651
|
+
current.callbacks.delete(element);
|
|
652
|
+
current.observer.unobserve(element);
|
|
653
|
+
current.count--;
|
|
654
|
+
if (current.count <= 0) {
|
|
655
|
+
current.observer.disconnect();
|
|
656
|
+
intersectionPools.delete(key);
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
var resizePools = /* @__PURE__ */ new Map();
|
|
661
|
+
function observeResize(element, callback, box = "content-box") {
|
|
662
|
+
if (typeof ResizeObserver === "undefined") return () => {
|
|
663
|
+
};
|
|
664
|
+
let pool = resizePools.get(box);
|
|
665
|
+
if (!pool) {
|
|
666
|
+
const callbacks = /* @__PURE__ */ new WeakMap();
|
|
667
|
+
const observer = new ResizeObserver((entries) => {
|
|
668
|
+
for (const entry of entries) {
|
|
669
|
+
const set2 = callbacks.get(entry.target);
|
|
670
|
+
if (!set2) continue;
|
|
671
|
+
const info = describeResize(entry);
|
|
672
|
+
for (const cb of [...set2]) cb(info);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
pool = { observer, callbacks, count: 0 };
|
|
676
|
+
resizePools.set(box, pool);
|
|
677
|
+
}
|
|
678
|
+
let set = pool.callbacks.get(element);
|
|
679
|
+
if (!set) {
|
|
680
|
+
set = /* @__PURE__ */ new Set();
|
|
681
|
+
pool.callbacks.set(element, set);
|
|
682
|
+
try {
|
|
683
|
+
pool.observer.observe(element, { box });
|
|
684
|
+
} catch {
|
|
685
|
+
pool.observer.observe(element);
|
|
686
|
+
}
|
|
687
|
+
pool.count++;
|
|
688
|
+
}
|
|
689
|
+
set.add(callback);
|
|
690
|
+
let released = false;
|
|
691
|
+
return () => {
|
|
692
|
+
if (released) return;
|
|
693
|
+
released = true;
|
|
694
|
+
const current = resizePools.get(box);
|
|
695
|
+
if (!current) return;
|
|
696
|
+
const callbackSet = current.callbacks.get(element);
|
|
697
|
+
if (!callbackSet) return;
|
|
698
|
+
callbackSet.delete(callback);
|
|
699
|
+
if (callbackSet.size > 0) return;
|
|
700
|
+
current.callbacks.delete(element);
|
|
701
|
+
current.observer.unobserve(element);
|
|
702
|
+
current.count--;
|
|
703
|
+
if (current.count <= 0) {
|
|
704
|
+
current.observer.disconnect();
|
|
705
|
+
resizePools.delete(box);
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
function describeResize(entry) {
|
|
710
|
+
const dpr = typeof devicePixelRatio === "number" ? devicePixelRatio : 1;
|
|
711
|
+
const contentBox = entry.contentBoxSize?.[0];
|
|
712
|
+
const width = contentBox?.inlineSize ?? entry.contentRect.width;
|
|
713
|
+
const height = contentBox?.blockSize ?? entry.contentRect.height;
|
|
714
|
+
const devicePixelBox = entry.devicePixelContentBoxSize?.[0];
|
|
715
|
+
return {
|
|
716
|
+
width,
|
|
717
|
+
height,
|
|
718
|
+
devicePixelWidth: devicePixelBox?.inlineSize ?? Math.round(width * dpr),
|
|
719
|
+
devicePixelHeight: devicePixelBox?.blockSize ?? Math.round(height * dpr),
|
|
720
|
+
entry
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
function observerPoolStats() {
|
|
724
|
+
return { intersection: intersectionPools.size, resize: resizePools.size };
|
|
725
|
+
}
|
|
726
|
+
function resetObserverPools() {
|
|
727
|
+
for (const pool of intersectionPools.values()) pool.observer.disconnect();
|
|
728
|
+
for (const pool of resizePools.values()) pool.observer.disconnect();
|
|
729
|
+
intersectionPools.clear();
|
|
730
|
+
resizePools.clear();
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// src/observers/pointer.ts
|
|
734
|
+
function trackPointer(element, options = {}) {
|
|
735
|
+
const smoothing = options.smoothing ?? 5e-3;
|
|
736
|
+
const trackOutside = options.trackOutside ?? false;
|
|
737
|
+
const originX = options.origin?.x ?? 0.5;
|
|
738
|
+
const originY = options.origin?.y ?? 0.5;
|
|
739
|
+
let rect = { left: 0, top: 0, width: 1, height: 1 };
|
|
740
|
+
const state = {
|
|
741
|
+
x: 0,
|
|
742
|
+
y: 0,
|
|
743
|
+
nx: originX,
|
|
744
|
+
ny: originY,
|
|
745
|
+
smoothX: 0,
|
|
746
|
+
smoothY: 0,
|
|
747
|
+
smoothNx: originX,
|
|
748
|
+
smoothNy: originY,
|
|
749
|
+
velocityX: 0,
|
|
750
|
+
velocityY: 0,
|
|
751
|
+
inside: false,
|
|
752
|
+
down: false,
|
|
753
|
+
pressure: 0.5,
|
|
754
|
+
type: "none",
|
|
755
|
+
everInside: false
|
|
756
|
+
};
|
|
757
|
+
let lastX = 0;
|
|
758
|
+
let lastY = 0;
|
|
759
|
+
let hasLast = false;
|
|
760
|
+
let targetX = 0;
|
|
761
|
+
let targetY = 0;
|
|
762
|
+
const refresh = () => {
|
|
763
|
+
const box = element.getBoundingClientRect();
|
|
764
|
+
rect = {
|
|
765
|
+
left: box.left,
|
|
766
|
+
top: box.top,
|
|
767
|
+
width: Math.max(1, box.width),
|
|
768
|
+
height: Math.max(1, box.height)
|
|
769
|
+
};
|
|
770
|
+
if (!state.everInside) {
|
|
771
|
+
targetX = rect.width * originX;
|
|
772
|
+
targetY = rect.height * originY;
|
|
773
|
+
state.smoothX = targetX;
|
|
774
|
+
state.smoothY = targetY;
|
|
775
|
+
state.x = targetX;
|
|
776
|
+
state.y = targetY;
|
|
777
|
+
}
|
|
778
|
+
};
|
|
779
|
+
refresh();
|
|
780
|
+
const onPointerMove = (event) => {
|
|
781
|
+
const x = event.clientX - rect.left;
|
|
782
|
+
const y = event.clientY - rect.top;
|
|
783
|
+
const inside = x >= 0 && x <= rect.width && y >= 0 && y <= rect.height;
|
|
784
|
+
if (!inside && !trackOutside && !state.down) {
|
|
785
|
+
state.inside = false;
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
state.x = x;
|
|
789
|
+
state.y = y;
|
|
790
|
+
state.nx = clamp(x / rect.width, 0, 1);
|
|
791
|
+
state.ny = clamp(y / rect.height, 0, 1);
|
|
792
|
+
state.inside = inside;
|
|
793
|
+
state.pressure = event.pressure > 0 ? event.pressure : 0.5;
|
|
794
|
+
state.type = event.pointerType || "mouse";
|
|
795
|
+
if (inside) state.everInside = true;
|
|
796
|
+
targetX = x;
|
|
797
|
+
targetY = y;
|
|
798
|
+
if (hasLast) {
|
|
799
|
+
const dt = Math.max(1 / 1e3, (event.timeStamp - lastTimeStamp) / 1e3);
|
|
800
|
+
state.velocityX = (x - lastX) / dt;
|
|
801
|
+
state.velocityY = (y - lastY) / dt;
|
|
802
|
+
}
|
|
803
|
+
lastX = x;
|
|
804
|
+
lastY = y;
|
|
805
|
+
lastTimeStamp = event.timeStamp;
|
|
806
|
+
hasLast = true;
|
|
807
|
+
};
|
|
808
|
+
let lastTimeStamp = 0;
|
|
809
|
+
const onPointerEnter = (event) => {
|
|
810
|
+
state.inside = true;
|
|
811
|
+
state.everInside = true;
|
|
812
|
+
state.type = event.pointerType || "mouse";
|
|
813
|
+
hasLast = false;
|
|
814
|
+
};
|
|
815
|
+
const onPointerLeave = () => {
|
|
816
|
+
state.inside = false;
|
|
817
|
+
state.down = false;
|
|
818
|
+
state.velocityX = 0;
|
|
819
|
+
state.velocityY = 0;
|
|
820
|
+
hasLast = false;
|
|
821
|
+
targetX = rect.width * originX;
|
|
822
|
+
targetY = rect.height * originY;
|
|
823
|
+
};
|
|
824
|
+
const onPointerDown = (event) => {
|
|
825
|
+
state.down = true;
|
|
826
|
+
state.pressure = event.pressure > 0 ? event.pressure : 0.5;
|
|
827
|
+
onPointerMove(event);
|
|
828
|
+
};
|
|
829
|
+
const onPointerUp = (event) => {
|
|
830
|
+
state.down = false;
|
|
831
|
+
state.pressure = 0;
|
|
832
|
+
if (event.pointerType === "touch") onPointerLeave();
|
|
833
|
+
};
|
|
834
|
+
const passive = { passive: true };
|
|
835
|
+
element.addEventListener("pointerenter", onPointerEnter, passive);
|
|
836
|
+
element.addEventListener("pointerleave", onPointerLeave, passive);
|
|
837
|
+
element.addEventListener("pointerdown", onPointerDown, passive);
|
|
838
|
+
element.addEventListener("pointercancel", onPointerLeave, passive);
|
|
839
|
+
const moveTarget = trackOutside ? window : element;
|
|
840
|
+
moveTarget.addEventListener("pointermove", onPointerMove, passive);
|
|
841
|
+
window.addEventListener("pointerup", onPointerUp, passive);
|
|
842
|
+
const onScroll = () => refresh();
|
|
843
|
+
window.addEventListener("scroll", onScroll, { passive: true, capture: true });
|
|
844
|
+
window.addEventListener("resize", onScroll, passive);
|
|
845
|
+
const ticker = getTicker();
|
|
846
|
+
const stopRead = ticker.add(
|
|
847
|
+
() => {
|
|
848
|
+
const box = element.getBoundingClientRect();
|
|
849
|
+
if (box.left !== rect.left || box.top !== rect.top || box.width !== rect.width) {
|
|
850
|
+
rect = {
|
|
851
|
+
left: box.left,
|
|
852
|
+
top: box.top,
|
|
853
|
+
width: Math.max(1, box.width),
|
|
854
|
+
height: Math.max(1, box.height)
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
},
|
|
858
|
+
{ phase: "read", name: "pointer:rect" }
|
|
859
|
+
);
|
|
860
|
+
const stopCompute = ticker.add(
|
|
861
|
+
({ delta }) => {
|
|
862
|
+
state.smoothX = damp(state.smoothX, targetX, smoothing, delta);
|
|
863
|
+
state.smoothY = damp(state.smoothY, targetY, smoothing, delta);
|
|
864
|
+
state.smoothNx = clamp(state.smoothX / rect.width, 0, 1);
|
|
865
|
+
state.smoothNy = clamp(state.smoothY / rect.height, 0, 1);
|
|
866
|
+
},
|
|
867
|
+
{ phase: "compute", name: "pointer:smooth" }
|
|
868
|
+
);
|
|
869
|
+
return {
|
|
870
|
+
state,
|
|
871
|
+
refresh,
|
|
872
|
+
destroy() {
|
|
873
|
+
element.removeEventListener("pointerenter", onPointerEnter);
|
|
874
|
+
element.removeEventListener("pointerleave", onPointerLeave);
|
|
875
|
+
element.removeEventListener("pointerdown", onPointerDown);
|
|
876
|
+
element.removeEventListener("pointercancel", onPointerLeave);
|
|
877
|
+
moveTarget.removeEventListener("pointermove", onPointerMove);
|
|
878
|
+
window.removeEventListener("pointerup", onPointerUp);
|
|
879
|
+
window.removeEventListener("scroll", onScroll, { capture: true });
|
|
880
|
+
window.removeEventListener("resize", onScroll);
|
|
881
|
+
stopRead();
|
|
882
|
+
stopCompute();
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
// src/plugins/registry.ts
|
|
888
|
+
var CapabilityError = class extends Error {
|
|
889
|
+
constructor(capability, hint) {
|
|
890
|
+
super(
|
|
891
|
+
`[ansh] Missing capability "${capability}".` + (hint ? ` ${hint}` : "") + ` See https://ansh.dev/docs/capabilities for the full list.`
|
|
892
|
+
);
|
|
893
|
+
this.capability = capability;
|
|
894
|
+
this.name = "CapabilityError";
|
|
895
|
+
}
|
|
896
|
+
capability;
|
|
897
|
+
};
|
|
898
|
+
var Registry = class {
|
|
899
|
+
entries = /* @__PURE__ */ new Map();
|
|
900
|
+
plugins = /* @__PURE__ */ new Map();
|
|
901
|
+
capabilities = /* @__PURE__ */ new Set();
|
|
902
|
+
/** Human-readable reason a gated capability is unavailable, keyed by capability. */
|
|
903
|
+
gateHints = /* @__PURE__ */ new Map();
|
|
904
|
+
// -------------------------------------------------------------------------
|
|
905
|
+
// Namespaced value storage
|
|
906
|
+
// -------------------------------------------------------------------------
|
|
907
|
+
/**
|
|
908
|
+
* Store a value under `namespace:key`.
|
|
909
|
+
*
|
|
910
|
+
* Namespacing keeps effects, behaviours, renderers and easings from colliding, and lets
|
|
911
|
+
* `list('effect')` enumerate one kind without filtering.
|
|
912
|
+
*/
|
|
913
|
+
register(namespace, key, value) {
|
|
914
|
+
let bucket = this.entries.get(namespace);
|
|
915
|
+
if (!bucket) {
|
|
916
|
+
bucket = /* @__PURE__ */ new Map();
|
|
917
|
+
this.entries.set(namespace, bucket);
|
|
918
|
+
}
|
|
919
|
+
if (bucket.has(key)) {
|
|
920
|
+
throw new Error(
|
|
921
|
+
`[ansh] "${key}" is already registered under "${namespace}". Call unregister("${namespace}", "${key}") first if replacement is intended.`
|
|
922
|
+
);
|
|
923
|
+
}
|
|
924
|
+
bucket.set(key, value);
|
|
925
|
+
}
|
|
926
|
+
get(namespace, key) {
|
|
927
|
+
return this.entries.get(namespace)?.get(key);
|
|
928
|
+
}
|
|
929
|
+
/** Like {@link get} but throws a helpful error rather than returning `undefined`. */
|
|
930
|
+
require(namespace, key) {
|
|
931
|
+
const value = this.get(namespace, key);
|
|
932
|
+
if (value === void 0) {
|
|
933
|
+
const available = this.list(namespace);
|
|
934
|
+
throw new Error(
|
|
935
|
+
`[ansh] No "${key}" registered under "${namespace}".` + (available.length > 0 ? ` Available: ${available.join(", ")}.` : "")
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
return value;
|
|
939
|
+
}
|
|
940
|
+
has(namespace, key) {
|
|
941
|
+
return this.entries.get(namespace)?.has(key) ?? false;
|
|
942
|
+
}
|
|
943
|
+
unregister(namespace, key) {
|
|
944
|
+
return this.entries.get(namespace)?.delete(key) ?? false;
|
|
945
|
+
}
|
|
946
|
+
list(namespace) {
|
|
947
|
+
return [...this.entries.get(namespace)?.keys() ?? []].sort();
|
|
948
|
+
}
|
|
949
|
+
entriesOf(namespace) {
|
|
950
|
+
return [...this.entries.get(namespace)?.entries() ?? []];
|
|
951
|
+
}
|
|
952
|
+
// -------------------------------------------------------------------------
|
|
953
|
+
// Capabilities
|
|
954
|
+
// -------------------------------------------------------------------------
|
|
955
|
+
provide(capability) {
|
|
956
|
+
this.capabilities.add(capability);
|
|
957
|
+
this.gateHints.delete(capability);
|
|
958
|
+
}
|
|
959
|
+
revoke(capability, hint) {
|
|
960
|
+
this.capabilities.delete(capability);
|
|
961
|
+
if (hint) this.gateHints.set(capability, hint);
|
|
962
|
+
}
|
|
963
|
+
supports(capability) {
|
|
964
|
+
return this.capabilities.has(capability);
|
|
965
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* Assert a capability is available.
|
|
968
|
+
*
|
|
969
|
+
* This is the edition gate. A Pro effect calls
|
|
970
|
+
* `registry.requireCapability('effect:pro')` in its setup, and in the Free edition that
|
|
971
|
+
* throws with a message explaining what to do — at mount time, with a stack trace pointing
|
|
972
|
+
* at the component, rather than as a mysteriously blank canvas.
|
|
973
|
+
*/
|
|
974
|
+
requireCapability(capability) {
|
|
975
|
+
if (!this.capabilities.has(capability)) {
|
|
976
|
+
throw new CapabilityError(capability, this.gateHints.get(capability));
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
listCapabilities() {
|
|
980
|
+
return [...this.capabilities].sort();
|
|
981
|
+
}
|
|
982
|
+
// -------------------------------------------------------------------------
|
|
983
|
+
// Plugins
|
|
984
|
+
// -------------------------------------------------------------------------
|
|
985
|
+
use(plugin) {
|
|
986
|
+
if (this.plugins.has(plugin.name)) return this;
|
|
987
|
+
for (const required of plugin.requires ?? []) {
|
|
988
|
+
if (!this.capabilities.has(required)) {
|
|
989
|
+
throw new CapabilityError(
|
|
990
|
+
required,
|
|
991
|
+
`Plugin "${plugin.name}" requires it. Install the providing plugin first.`
|
|
992
|
+
);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
plugin.install?.(this);
|
|
996
|
+
for (const capability of plugin.provides ?? []) this.provide(capability);
|
|
997
|
+
this.plugins.set(plugin.name, plugin);
|
|
998
|
+
return this;
|
|
999
|
+
}
|
|
1000
|
+
remove(name) {
|
|
1001
|
+
const plugin = this.plugins.get(name);
|
|
1002
|
+
if (!plugin) return false;
|
|
1003
|
+
plugin.uninstall?.(this);
|
|
1004
|
+
for (const capability of plugin.provides ?? []) this.capabilities.delete(capability);
|
|
1005
|
+
this.plugins.delete(name);
|
|
1006
|
+
return true;
|
|
1007
|
+
}
|
|
1008
|
+
listPlugins() {
|
|
1009
|
+
return [...this.plugins.values()].map((p) => ({ name: p.name, version: p.version ?? "0.0.0" }));
|
|
1010
|
+
}
|
|
1011
|
+
/** Wipe everything. Used between tests. */
|
|
1012
|
+
clear() {
|
|
1013
|
+
for (const name of [...this.plugins.keys()]) this.remove(name);
|
|
1014
|
+
this.entries.clear();
|
|
1015
|
+
this.capabilities.clear();
|
|
1016
|
+
this.gateHints.clear();
|
|
1017
|
+
}
|
|
1018
|
+
};
|
|
1019
|
+
var shared = null;
|
|
1020
|
+
function getRegistry() {
|
|
1021
|
+
if (shared === null) {
|
|
1022
|
+
shared = new Registry();
|
|
1023
|
+
shared.provide("renderer:canvas2d");
|
|
1024
|
+
shared.provide("effect:core");
|
|
1025
|
+
shared.provide("behaviour:core");
|
|
1026
|
+
}
|
|
1027
|
+
return shared;
|
|
1028
|
+
}
|
|
1029
|
+
function resetRegistry() {
|
|
1030
|
+
shared?.clear();
|
|
1031
|
+
shared = null;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
export { CapabilityError, Clock, FRAME_PHASES, FrameBudget, Registry, Ticker, getReducedMotionMode, getRegistry, getTicker, now, observeIntersection, observeResize, observerPoolStats, onReducedMotionChange, prefersReducedMotion, resetObserverPools, resetReducedMotion, resetRegistry, resetTicker, resolveMotion, setReducedMotionMode, setTicker, trackPointer };
|
|
1035
|
+
//# sourceMappingURL=chunk-BOQQHICI.js.map
|
|
1036
|
+
//# sourceMappingURL=chunk-BOQQHICI.js.map
|