@zeey4d/react-native-gesture-engine 1.0.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/README.md +246 -0
- package/dist/index.d.mts +1438 -0
- package/dist/index.d.ts +1438 -0
- package/dist/index.js +2448 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +2401 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +66 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,2401 @@
|
|
|
1
|
+
import { useRef, useState, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
4
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
5
|
+
}) : x)(function(x) {
|
|
6
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
7
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
// src/core/EventBus.ts
|
|
11
|
+
var EventBus = class {
|
|
12
|
+
constructor() {
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
14
|
+
this.listeners = /* @__PURE__ */ new Map();
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Subscribe to a channel. Returns an unsubscribe function.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* const unsub = bus.on(EventChannel.InputRaw, (event) => { ... });
|
|
21
|
+
* // later:
|
|
22
|
+
* unsub();
|
|
23
|
+
*/
|
|
24
|
+
on(channel, handler) {
|
|
25
|
+
if (!this.listeners.has(channel)) {
|
|
26
|
+
this.listeners.set(channel, /* @__PURE__ */ new Set());
|
|
27
|
+
}
|
|
28
|
+
this.listeners.get(channel).add(handler);
|
|
29
|
+
return () => this.off(channel, handler);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Emit data on a channel. All registered handlers are called synchronously.
|
|
33
|
+
* The generic parameter ensures the data type matches the channel.
|
|
34
|
+
*/
|
|
35
|
+
emit(channel, data) {
|
|
36
|
+
const handlers = this.listeners.get(channel);
|
|
37
|
+
if (!handlers) return;
|
|
38
|
+
for (const handler of Array.from(handlers)) {
|
|
39
|
+
try {
|
|
40
|
+
handler(data);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (typeof __DEV__ !== "undefined" && __DEV__) {
|
|
43
|
+
console.error(`[EventBus] Error in handler for ${channel}:`, error);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Remove a specific handler from a channel.
|
|
50
|
+
*/
|
|
51
|
+
off(channel, handler) {
|
|
52
|
+
const handlers = this.listeners.get(channel);
|
|
53
|
+
if (handlers) {
|
|
54
|
+
handlers.delete(handler);
|
|
55
|
+
if (handlers.size === 0) {
|
|
56
|
+
this.listeners.delete(channel);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Remove all handlers from all channels. Called during engine teardown.
|
|
62
|
+
*/
|
|
63
|
+
clear() {
|
|
64
|
+
this.listeners.clear();
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// src/core/types.ts
|
|
69
|
+
var InputType = /* @__PURE__ */ ((InputType2) => {
|
|
70
|
+
InputType2["Touch"] = "touch";
|
|
71
|
+
InputType2["Sensor"] = "sensor";
|
|
72
|
+
InputType2["Hardware"] = "hardware";
|
|
73
|
+
InputType2["Camera"] = "camera";
|
|
74
|
+
return InputType2;
|
|
75
|
+
})(InputType || {});
|
|
76
|
+
var TouchType = /* @__PURE__ */ ((TouchType2) => {
|
|
77
|
+
TouchType2["Pan"] = "pan";
|
|
78
|
+
TouchType2["Tap"] = "tap";
|
|
79
|
+
TouchType2["Pinch"] = "pinch";
|
|
80
|
+
TouchType2["Rotation"] = "rotation";
|
|
81
|
+
return TouchType2;
|
|
82
|
+
})(TouchType || {});
|
|
83
|
+
var SensorType = /* @__PURE__ */ ((SensorType2) => {
|
|
84
|
+
SensorType2["Accelerometer"] = "accelerometer";
|
|
85
|
+
SensorType2["Gyroscope"] = "gyroscope";
|
|
86
|
+
return SensorType2;
|
|
87
|
+
})(SensorType || {});
|
|
88
|
+
var CardinalDirection = /* @__PURE__ */ ((CardinalDirection3) => {
|
|
89
|
+
CardinalDirection3["Up"] = "up";
|
|
90
|
+
CardinalDirection3["Down"] = "down";
|
|
91
|
+
CardinalDirection3["Left"] = "left";
|
|
92
|
+
CardinalDirection3["Right"] = "right";
|
|
93
|
+
CardinalDirection3["UpLeft"] = "up-left";
|
|
94
|
+
CardinalDirection3["UpRight"] = "up-right";
|
|
95
|
+
CardinalDirection3["DownLeft"] = "down-left";
|
|
96
|
+
CardinalDirection3["DownRight"] = "down-right";
|
|
97
|
+
CardinalDirection3["None"] = "none";
|
|
98
|
+
return CardinalDirection3;
|
|
99
|
+
})(CardinalDirection || {});
|
|
100
|
+
var RecognizerState = /* @__PURE__ */ ((RecognizerState2) => {
|
|
101
|
+
RecognizerState2["Idle"] = "idle";
|
|
102
|
+
RecognizerState2["Possible"] = "possible";
|
|
103
|
+
RecognizerState2["Began"] = "began";
|
|
104
|
+
RecognizerState2["Changed"] = "changed";
|
|
105
|
+
RecognizerState2["Ended"] = "ended";
|
|
106
|
+
RecognizerState2["Failed"] = "failed";
|
|
107
|
+
RecognizerState2["Cancelled"] = "cancelled";
|
|
108
|
+
return RecognizerState2;
|
|
109
|
+
})(RecognizerState || {});
|
|
110
|
+
var EventChannel = /* @__PURE__ */ ((EventChannel2) => {
|
|
111
|
+
EventChannel2["InputRaw"] = "input:raw";
|
|
112
|
+
EventChannel2["ProcessingSample"] = "processing:sample";
|
|
113
|
+
EventChannel2["RecognitionGesture"] = "recognition:gesture";
|
|
114
|
+
EventChannel2["ConflictResolved"] = "conflict:resolved";
|
|
115
|
+
EventChannel2["ActionDispatched"] = "action:dispatched";
|
|
116
|
+
return EventChannel2;
|
|
117
|
+
})(EventChannel || {});
|
|
118
|
+
function generateId() {
|
|
119
|
+
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// src/input/TouchInputProvider.ts
|
|
123
|
+
var TouchInputProvider = class {
|
|
124
|
+
constructor(eventBus) {
|
|
125
|
+
this.eventBus = eventBus;
|
|
126
|
+
this._isActive = false;
|
|
127
|
+
}
|
|
128
|
+
get isActive() {
|
|
129
|
+
return this._isActive;
|
|
130
|
+
}
|
|
131
|
+
start() {
|
|
132
|
+
this._isActive = true;
|
|
133
|
+
}
|
|
134
|
+
stop() {
|
|
135
|
+
this._isActive = false;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Called from RNGH Pan gesture callbacks.
|
|
139
|
+
* Emits normalized TouchData with translation and velocity.
|
|
140
|
+
*/
|
|
141
|
+
onPan(data) {
|
|
142
|
+
if (!this._isActive) return;
|
|
143
|
+
const touchData = {
|
|
144
|
+
type: "pan" /* Pan */,
|
|
145
|
+
x: data.x,
|
|
146
|
+
y: data.y,
|
|
147
|
+
translationX: data.translationX,
|
|
148
|
+
translationY: data.translationY,
|
|
149
|
+
velocityX: data.velocityX,
|
|
150
|
+
velocityY: data.velocityY,
|
|
151
|
+
scale: 1,
|
|
152
|
+
rotation: 0,
|
|
153
|
+
numberOfPointers: data.numberOfPointers
|
|
154
|
+
};
|
|
155
|
+
this.emitInput(touchData);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Called from RNGH Tap gesture callbacks.
|
|
159
|
+
*/
|
|
160
|
+
onTap(data) {
|
|
161
|
+
if (!this._isActive) return;
|
|
162
|
+
const touchData = {
|
|
163
|
+
type: "tap" /* Tap */,
|
|
164
|
+
x: data.x,
|
|
165
|
+
y: data.y,
|
|
166
|
+
translationX: 0,
|
|
167
|
+
translationY: 0,
|
|
168
|
+
velocityX: 0,
|
|
169
|
+
velocityY: 0,
|
|
170
|
+
scale: 1,
|
|
171
|
+
rotation: 0,
|
|
172
|
+
numberOfPointers: data.numberOfPointers
|
|
173
|
+
};
|
|
174
|
+
this.emitInput(touchData);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Called from RNGH Pinch gesture callbacks.
|
|
178
|
+
*/
|
|
179
|
+
onPinch(data) {
|
|
180
|
+
if (!this._isActive) return;
|
|
181
|
+
const touchData = {
|
|
182
|
+
type: "pinch" /* Pinch */,
|
|
183
|
+
x: data.focalX,
|
|
184
|
+
y: data.focalY,
|
|
185
|
+
translationX: 0,
|
|
186
|
+
translationY: 0,
|
|
187
|
+
velocityX: 0,
|
|
188
|
+
velocityY: data.velocity,
|
|
189
|
+
scale: data.scale,
|
|
190
|
+
rotation: 0,
|
|
191
|
+
numberOfPointers: data.numberOfPointers
|
|
192
|
+
};
|
|
193
|
+
this.emitInput(touchData);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Called from RNGH Rotation gesture callbacks.
|
|
197
|
+
*/
|
|
198
|
+
onRotation(data) {
|
|
199
|
+
if (!this._isActive) return;
|
|
200
|
+
const touchData = {
|
|
201
|
+
type: "rotation" /* Rotation */,
|
|
202
|
+
x: data.anchorX,
|
|
203
|
+
y: data.anchorY,
|
|
204
|
+
translationX: 0,
|
|
205
|
+
translationY: 0,
|
|
206
|
+
velocityX: 0,
|
|
207
|
+
velocityY: data.velocity,
|
|
208
|
+
scale: 1,
|
|
209
|
+
rotation: data.rotation,
|
|
210
|
+
numberOfPointers: data.numberOfPointers
|
|
211
|
+
};
|
|
212
|
+
this.emitInput(touchData);
|
|
213
|
+
}
|
|
214
|
+
/** Emit a normalized InputEvent onto the EventBus */
|
|
215
|
+
emitInput(touchData) {
|
|
216
|
+
const event = {
|
|
217
|
+
id: generateId(),
|
|
218
|
+
timestamp: Date.now(),
|
|
219
|
+
inputType: "touch" /* Touch */,
|
|
220
|
+
data: touchData
|
|
221
|
+
};
|
|
222
|
+
this.eventBus.emit("input:raw" /* InputRaw */, event);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
// src/input/SensorInputProvider.ts
|
|
227
|
+
var Accelerometer;
|
|
228
|
+
var Gyroscope;
|
|
229
|
+
function loadSensorModules() {
|
|
230
|
+
try {
|
|
231
|
+
const sensors = __require("expo-sensors");
|
|
232
|
+
Accelerometer = sensors.Accelerometer;
|
|
233
|
+
Gyroscope = sensors.Gyroscope;
|
|
234
|
+
} catch {
|
|
235
|
+
console.warn(
|
|
236
|
+
"[GestureEngine] expo-sensors not found. SensorInputProvider will not function."
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
var SensorInputProvider = class {
|
|
241
|
+
constructor(eventBus, updateIntervalMs = 100) {
|
|
242
|
+
this.eventBus = eventBus;
|
|
243
|
+
this._isActive = false;
|
|
244
|
+
this.accelSubscription = null;
|
|
245
|
+
this.gyroSubscription = null;
|
|
246
|
+
this.updateIntervalMs = Math.max(16, updateIntervalMs);
|
|
247
|
+
loadSensorModules();
|
|
248
|
+
}
|
|
249
|
+
get isActive() {
|
|
250
|
+
return this._isActive;
|
|
251
|
+
}
|
|
252
|
+
start() {
|
|
253
|
+
if (this._isActive) return;
|
|
254
|
+
this._isActive = true;
|
|
255
|
+
if (!Accelerometer || !Gyroscope) {
|
|
256
|
+
console.warn("[GestureEngine] Sensors unavailable. Skipping sensor subscriptions.");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
Accelerometer.setUpdateInterval(this.updateIntervalMs);
|
|
260
|
+
Gyroscope.setUpdateInterval(this.updateIntervalMs);
|
|
261
|
+
this.accelSubscription = Accelerometer.addListener(
|
|
262
|
+
(data) => {
|
|
263
|
+
if (!this._isActive) return;
|
|
264
|
+
const sensorData = {
|
|
265
|
+
type: "accelerometer" /* Accelerometer */,
|
|
266
|
+
x: data.x,
|
|
267
|
+
y: data.y,
|
|
268
|
+
z: data.z
|
|
269
|
+
};
|
|
270
|
+
const event = {
|
|
271
|
+
id: generateId(),
|
|
272
|
+
timestamp: Date.now(),
|
|
273
|
+
inputType: "sensor" /* Sensor */,
|
|
274
|
+
data: sensorData
|
|
275
|
+
};
|
|
276
|
+
this.eventBus.emit("input:raw" /* InputRaw */, event);
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
this.gyroSubscription = Gyroscope.addListener(
|
|
280
|
+
(data) => {
|
|
281
|
+
if (!this._isActive) return;
|
|
282
|
+
const sensorData = {
|
|
283
|
+
type: "gyroscope" /* Gyroscope */,
|
|
284
|
+
x: data.x,
|
|
285
|
+
y: data.y,
|
|
286
|
+
z: data.z
|
|
287
|
+
};
|
|
288
|
+
const event = {
|
|
289
|
+
id: generateId(),
|
|
290
|
+
timestamp: Date.now(),
|
|
291
|
+
inputType: "sensor" /* Sensor */,
|
|
292
|
+
data: sensorData
|
|
293
|
+
};
|
|
294
|
+
this.eventBus.emit("input:raw" /* InputRaw */, event);
|
|
295
|
+
}
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
stop() {
|
|
299
|
+
this._isActive = false;
|
|
300
|
+
if (this.accelSubscription) {
|
|
301
|
+
this.accelSubscription.remove();
|
|
302
|
+
this.accelSubscription = null;
|
|
303
|
+
}
|
|
304
|
+
if (this.gyroSubscription) {
|
|
305
|
+
this.gyroSubscription.remove();
|
|
306
|
+
this.gyroSubscription = null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// src/input/HardwareInputProvider.ts
|
|
312
|
+
var DeviceEventEmitter;
|
|
313
|
+
function loadEmitter() {
|
|
314
|
+
try {
|
|
315
|
+
const rn = __require("react-native");
|
|
316
|
+
DeviceEventEmitter = rn.DeviceEventEmitter;
|
|
317
|
+
} catch {
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
var HardwareInputProvider = class {
|
|
321
|
+
constructor(eventBus, eventName = "onHardwareKey") {
|
|
322
|
+
this.eventBus = eventBus;
|
|
323
|
+
this._isActive = false;
|
|
324
|
+
this.subscription = null;
|
|
325
|
+
this.eventName = eventName;
|
|
326
|
+
loadEmitter();
|
|
327
|
+
}
|
|
328
|
+
get isActive() {
|
|
329
|
+
return this._isActive;
|
|
330
|
+
}
|
|
331
|
+
start() {
|
|
332
|
+
if (this._isActive) return;
|
|
333
|
+
this._isActive = true;
|
|
334
|
+
if (!DeviceEventEmitter) {
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
this.subscription = DeviceEventEmitter.addListener(
|
|
338
|
+
this.eventName,
|
|
339
|
+
(data) => {
|
|
340
|
+
if (!this._isActive) return;
|
|
341
|
+
const hardwareData = {
|
|
342
|
+
key: data.key,
|
|
343
|
+
action: data.action
|
|
344
|
+
};
|
|
345
|
+
const event = {
|
|
346
|
+
id: generateId(),
|
|
347
|
+
timestamp: Date.now(),
|
|
348
|
+
inputType: "hardware" /* Hardware */,
|
|
349
|
+
data: hardwareData
|
|
350
|
+
};
|
|
351
|
+
this.eventBus.emit("input:raw" /* InputRaw */, event);
|
|
352
|
+
}
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
stop() {
|
|
356
|
+
this._isActive = false;
|
|
357
|
+
if (this.subscription) {
|
|
358
|
+
this.subscription.remove();
|
|
359
|
+
this.subscription = null;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// src/input/CameraInputProvider.ts
|
|
365
|
+
var CameraInputProvider = class {
|
|
366
|
+
// EventBus stored for future use when camera input is implemented
|
|
367
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
368
|
+
constructor(eventBus) {
|
|
369
|
+
this.eventBus = eventBus;
|
|
370
|
+
this._isActive = false;
|
|
371
|
+
}
|
|
372
|
+
get isActive() {
|
|
373
|
+
return this._isActive;
|
|
374
|
+
}
|
|
375
|
+
start() {
|
|
376
|
+
this._isActive = true;
|
|
377
|
+
}
|
|
378
|
+
stop() {
|
|
379
|
+
this._isActive = false;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
// src/processing/NoiseFilter.ts
|
|
384
|
+
var NoiseFilter = class {
|
|
385
|
+
/**
|
|
386
|
+
* @param alpha - Filter coefficient [0, 1]. Default 0.8.
|
|
387
|
+
* - For low-pass: 0.1 = very smooth, 0.9 = barely filtered
|
|
388
|
+
* - For high-pass: same alpha applied to the underlying low-pass
|
|
389
|
+
*/
|
|
390
|
+
constructor(alpha = 0.8) {
|
|
391
|
+
this.lowPassState = null;
|
|
392
|
+
this.alpha = Math.max(0, Math.min(1, alpha));
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Apply low-pass filter. Removes high-frequency jitter.
|
|
396
|
+
*/
|
|
397
|
+
lowPass(x, y, z) {
|
|
398
|
+
if (this.lowPassState === null) {
|
|
399
|
+
this.lowPassState = { x, y, z };
|
|
400
|
+
return { x, y, z };
|
|
401
|
+
}
|
|
402
|
+
const prev = this.lowPassState;
|
|
403
|
+
const filtered = {
|
|
404
|
+
x: this.alpha * x + (1 - this.alpha) * prev.x,
|
|
405
|
+
y: this.alpha * y + (1 - this.alpha) * prev.y,
|
|
406
|
+
z: this.alpha * z + (1 - this.alpha) * prev.z
|
|
407
|
+
};
|
|
408
|
+
this.lowPassState = filtered;
|
|
409
|
+
return filtered;
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Apply high-pass filter. Removes low-frequency components (gravity).
|
|
413
|
+
* Returns only the dynamic/transient part of the signal.
|
|
414
|
+
*/
|
|
415
|
+
highPass(x, y, z) {
|
|
416
|
+
const low = this.lowPass(x, y, z);
|
|
417
|
+
return {
|
|
418
|
+
x: x - low.x,
|
|
419
|
+
y: y - low.y,
|
|
420
|
+
z: z - low.z
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Reset filter state. Call when starting a new gesture or after a pause.
|
|
425
|
+
*/
|
|
426
|
+
reset() {
|
|
427
|
+
this.lowPassState = null;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Update alpha dynamically (e.g., for adaptive filtering).
|
|
431
|
+
*/
|
|
432
|
+
setAlpha(alpha) {
|
|
433
|
+
this.alpha = Math.max(0, Math.min(1, alpha));
|
|
434
|
+
}
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
// src/processing/VelocityCalculator.ts
|
|
438
|
+
var VelocityCalculator = class {
|
|
439
|
+
constructor() {
|
|
440
|
+
this.prevX = null;
|
|
441
|
+
this.prevY = null;
|
|
442
|
+
this.prevTimestamp = null;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Calculate velocity from a new position sample.
|
|
446
|
+
*
|
|
447
|
+
* @param x - Current X position
|
|
448
|
+
* @param y - Current Y position
|
|
449
|
+
* @param timestamp - Current timestamp in milliseconds
|
|
450
|
+
* @returns Velocity components and magnitude
|
|
451
|
+
*/
|
|
452
|
+
calculate(x, y, timestamp) {
|
|
453
|
+
if (this.prevX === null || this.prevY === null || this.prevTimestamp === null) {
|
|
454
|
+
this.prevX = x;
|
|
455
|
+
this.prevY = y;
|
|
456
|
+
this.prevTimestamp = timestamp;
|
|
457
|
+
return { velocityX: 0, velocityY: 0, velocity: 0 };
|
|
458
|
+
}
|
|
459
|
+
const dt = timestamp - this.prevTimestamp;
|
|
460
|
+
if (dt <= 0) {
|
|
461
|
+
return { velocityX: 0, velocityY: 0, velocity: 0 };
|
|
462
|
+
}
|
|
463
|
+
const dx = x - this.prevX;
|
|
464
|
+
const dy = y - this.prevY;
|
|
465
|
+
const velocityX = dx / dt;
|
|
466
|
+
const velocityY = dy / dt;
|
|
467
|
+
const velocity = Math.sqrt(velocityX * velocityX + velocityY * velocityY);
|
|
468
|
+
this.prevX = x;
|
|
469
|
+
this.prevY = y;
|
|
470
|
+
this.prevTimestamp = timestamp;
|
|
471
|
+
return { velocityX, velocityY, velocity };
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Reset state. Call when starting a new gesture.
|
|
475
|
+
*/
|
|
476
|
+
reset() {
|
|
477
|
+
this.prevX = null;
|
|
478
|
+
this.prevY = null;
|
|
479
|
+
this.prevTimestamp = null;
|
|
480
|
+
}
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
// src/processing/AngleDetector.ts
|
|
484
|
+
var AngleDetector = class {
|
|
485
|
+
/**
|
|
486
|
+
* Calculate angle from X/Y deltas.
|
|
487
|
+
*
|
|
488
|
+
* @param dx - Change in X (positive = right)
|
|
489
|
+
* @param dy - Change in Y (positive = down in screen coords)
|
|
490
|
+
* @returns Angle in radians, degrees, and cardinal direction
|
|
491
|
+
*/
|
|
492
|
+
calculate(dx, dy) {
|
|
493
|
+
if (dx === 0 && dy === 0) {
|
|
494
|
+
return {
|
|
495
|
+
angleRadians: 0,
|
|
496
|
+
angleDegrees: 0,
|
|
497
|
+
direction: "none" /* None */
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
const angleRadians = Math.atan2(dy, dx);
|
|
501
|
+
const angleDegrees = angleRadians * 180 / Math.PI;
|
|
502
|
+
const direction = this.classifyDirection(angleDegrees);
|
|
503
|
+
return { angleRadians, angleDegrees, direction };
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Classify angle (in degrees) into 8 cardinal directions.
|
|
507
|
+
* Uses 45° sectors centered on each direction.
|
|
508
|
+
*
|
|
509
|
+
* Sectors (in screen coordinates where Y grows down):
|
|
510
|
+
* Right: -22.5° to 22.5°
|
|
511
|
+
* DownRight: 22.5° to 67.5°
|
|
512
|
+
* Down: 67.5° to 112.5°
|
|
513
|
+
* DownLeft: 112.5° to 157.5°
|
|
514
|
+
* Left: 157.5° to 180° or -180° to -157.5°
|
|
515
|
+
* UpLeft: -157.5° to -112.5°
|
|
516
|
+
* Up: -112.5° to -67.5°
|
|
517
|
+
* UpRight: -67.5° to -22.5°
|
|
518
|
+
*/
|
|
519
|
+
classifyDirection(degrees) {
|
|
520
|
+
const d = degrees;
|
|
521
|
+
if (d >= -22.5 && d < 22.5) return "right" /* Right */;
|
|
522
|
+
if (d >= 22.5 && d < 67.5) return "down-right" /* DownRight */;
|
|
523
|
+
if (d >= 67.5 && d < 112.5) return "down" /* Down */;
|
|
524
|
+
if (d >= 112.5 && d < 157.5) return "down-left" /* DownLeft */;
|
|
525
|
+
if (d >= 157.5 || d < -157.5) return "left" /* Left */;
|
|
526
|
+
if (d >= -157.5 && d < -112.5) return "up-left" /* UpLeft */;
|
|
527
|
+
if (d >= -112.5 && d < -67.5) return "up" /* Up */;
|
|
528
|
+
if (d >= -67.5 && d < -22.5) return "up-right" /* UpRight */;
|
|
529
|
+
return "none" /* None */;
|
|
530
|
+
}
|
|
531
|
+
};
|
|
532
|
+
|
|
533
|
+
// src/processing/ThresholdNormalizer.ts
|
|
534
|
+
var ThresholdNormalizer = class {
|
|
535
|
+
/**
|
|
536
|
+
* @param min - Minimum threshold. Values at or below this map to 0.
|
|
537
|
+
* @param max - Maximum threshold. Values at or above this map to 1.
|
|
538
|
+
*/
|
|
539
|
+
constructor(min = 0, max = 1) {
|
|
540
|
+
if (min >= max) {
|
|
541
|
+
throw new Error(
|
|
542
|
+
`[ThresholdNormalizer] min (${min}) must be less than max (${max})`
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
this.min = min;
|
|
546
|
+
this.max = max;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Normalize a raw value to [0, 1].
|
|
550
|
+
*/
|
|
551
|
+
normalize(value) {
|
|
552
|
+
if (value <= this.min) return 0;
|
|
553
|
+
if (value >= this.max) return 1;
|
|
554
|
+
return (value - this.min) / (this.max - this.min);
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Update thresholds dynamically.
|
|
558
|
+
*/
|
|
559
|
+
setRange(min, max) {
|
|
560
|
+
if (min >= max) {
|
|
561
|
+
throw new Error(
|
|
562
|
+
`[ThresholdNormalizer] min (${min}) must be less than max (${max})`
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
this.min = min;
|
|
566
|
+
this.max = max;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Get current min threshold.
|
|
570
|
+
*/
|
|
571
|
+
getMin() {
|
|
572
|
+
return this.min;
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Get current max threshold.
|
|
576
|
+
*/
|
|
577
|
+
getMax() {
|
|
578
|
+
return this.max;
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
// src/processing/StreamBuffer.ts
|
|
583
|
+
var StreamBuffer = class {
|
|
584
|
+
/**
|
|
585
|
+
* @param windowMs - Time window in ms. Samples older than this are evicted. Default 400.
|
|
586
|
+
* @param capacity - Maximum buffer size. Default 64 (~1 sec at 60Hz).
|
|
587
|
+
*/
|
|
588
|
+
constructor(windowMs = 400, capacity = 64) {
|
|
589
|
+
this.head = 0;
|
|
590
|
+
this.count = 0;
|
|
591
|
+
this.windowMs = windowMs;
|
|
592
|
+
this.capacity = capacity;
|
|
593
|
+
this.buffer = new Array(capacity).fill(null);
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Push a new sample. Automatically evicts stale samples.
|
|
597
|
+
* O(1) amortized.
|
|
598
|
+
*/
|
|
599
|
+
push(sample) {
|
|
600
|
+
const writeIndex = (this.head + this.count) % this.capacity;
|
|
601
|
+
if (this.count === this.capacity) {
|
|
602
|
+
this.buffer[this.head] = sample;
|
|
603
|
+
this.head = (this.head + 1) % this.capacity;
|
|
604
|
+
} else {
|
|
605
|
+
this.buffer[writeIndex] = sample;
|
|
606
|
+
this.count++;
|
|
607
|
+
}
|
|
608
|
+
this.evictStale(sample.timestamp);
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Get all non-stale samples in chronological order.
|
|
612
|
+
*/
|
|
613
|
+
getAll() {
|
|
614
|
+
const result = [];
|
|
615
|
+
for (let i = 0; i < this.count; i++) {
|
|
616
|
+
const index = (this.head + i) % this.capacity;
|
|
617
|
+
const sample = this.buffer[index];
|
|
618
|
+
if (sample) {
|
|
619
|
+
result.push(sample);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
return result;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Get the most recent sample, or null if buffer is empty.
|
|
626
|
+
*/
|
|
627
|
+
latest() {
|
|
628
|
+
if (this.count === 0) return null;
|
|
629
|
+
const index = (this.head + this.count - 1) % this.capacity;
|
|
630
|
+
return this.buffer[index];
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Get the number of samples currently in the buffer.
|
|
634
|
+
*/
|
|
635
|
+
size() {
|
|
636
|
+
return this.count;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Clear the buffer.
|
|
640
|
+
*/
|
|
641
|
+
clear() {
|
|
642
|
+
this.buffer.fill(null);
|
|
643
|
+
this.head = 0;
|
|
644
|
+
this.count = 0;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Remove samples older than windowMs from the head.
|
|
648
|
+
*/
|
|
649
|
+
evictStale(currentTimestamp) {
|
|
650
|
+
const cutoff = currentTimestamp - this.windowMs;
|
|
651
|
+
while (this.count > 0) {
|
|
652
|
+
const sample = this.buffer[this.head];
|
|
653
|
+
if (sample && sample.timestamp < cutoff) {
|
|
654
|
+
this.buffer[this.head] = null;
|
|
655
|
+
this.head = (this.head + 1) % this.capacity;
|
|
656
|
+
this.count--;
|
|
657
|
+
} else {
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
|
|
664
|
+
// src/recognition/base/BaseRecognizer.ts
|
|
665
|
+
var BaseRecognizer = class {
|
|
666
|
+
constructor(name, eventBus, options = {}) {
|
|
667
|
+
this._state = "idle" /* Idle */;
|
|
668
|
+
this.id = generateId();
|
|
669
|
+
this.name = name;
|
|
670
|
+
this.eventBus = eventBus;
|
|
671
|
+
this.priority = options.priority ?? 100;
|
|
672
|
+
this.isExclusive = options.isExclusive ?? false;
|
|
673
|
+
this.enabled = options.enabled ?? true;
|
|
674
|
+
}
|
|
675
|
+
get state() {
|
|
676
|
+
return this._state;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Reset the recognizer to Idle state.
|
|
680
|
+
*/
|
|
681
|
+
reset() {
|
|
682
|
+
this._state = "idle" /* Idle */;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Clean up resources. Override in subclasses for custom cleanup.
|
|
686
|
+
*/
|
|
687
|
+
dispose() {
|
|
688
|
+
this.reset();
|
|
689
|
+
}
|
|
690
|
+
// ─── Protected: state transition helpers ────────────────────────────
|
|
691
|
+
/**
|
|
692
|
+
* Transition to Possible state (gesture might be starting).
|
|
693
|
+
*/
|
|
694
|
+
transitionToPossible() {
|
|
695
|
+
if (this._state === "idle" /* Idle */) {
|
|
696
|
+
this._state = "possible" /* Possible */;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Transition to Began state and emit gesture event.
|
|
701
|
+
* Only valid from Possible state.
|
|
702
|
+
*/
|
|
703
|
+
transitionToBegan(metadata = {}) {
|
|
704
|
+
if (this._state === "possible" /* Possible */) {
|
|
705
|
+
this._state = "began" /* Began */;
|
|
706
|
+
this.emitGestureEvent(metadata);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Transition to Changed state and emit gesture event.
|
|
711
|
+
* Only valid from Began or Changed state (continuous gestures).
|
|
712
|
+
*/
|
|
713
|
+
transitionToChanged(metadata = {}) {
|
|
714
|
+
if (this._state === "began" /* Began */ || this._state === "changed" /* Changed */) {
|
|
715
|
+
this._state = "changed" /* Changed */;
|
|
716
|
+
this.emitGestureEvent(metadata);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Transition to Ended state and emit gesture event.
|
|
721
|
+
* Valid from Began, Changed, or Possible states.
|
|
722
|
+
*/
|
|
723
|
+
transitionToEnded(metadata = {}) {
|
|
724
|
+
if (this._state === "began" /* Began */ || this._state === "changed" /* Changed */ || this._state === "possible" /* Possible */) {
|
|
725
|
+
this._state = "ended" /* Ended */;
|
|
726
|
+
this.emitGestureEvent(metadata);
|
|
727
|
+
this._state = "idle" /* Idle */;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Transition to Failed state (gesture didn't match criteria).
|
|
732
|
+
* Auto-resets to Idle.
|
|
733
|
+
*/
|
|
734
|
+
transitionToFailed() {
|
|
735
|
+
if (this._state === "possible" /* Possible */ || this._state === "began" /* Began */) {
|
|
736
|
+
this._state = "failed" /* Failed */;
|
|
737
|
+
this._state = "idle" /* Idle */;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* Transition to Cancelled state (gesture was interrupted).
|
|
742
|
+
* Auto-resets to Idle.
|
|
743
|
+
*/
|
|
744
|
+
transitionToCancelled() {
|
|
745
|
+
if (this._state === "began" /* Began */ || this._state === "changed" /* Changed */) {
|
|
746
|
+
this._state = "cancelled" /* Cancelled */;
|
|
747
|
+
this.emitGestureEvent({});
|
|
748
|
+
this._state = "idle" /* Idle */;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Emit a GestureEvent on the RecognitionGesture channel.
|
|
753
|
+
*/
|
|
754
|
+
emitGestureEvent(metadata) {
|
|
755
|
+
const event = {
|
|
756
|
+
id: generateId(),
|
|
757
|
+
name: this.name,
|
|
758
|
+
state: this._state,
|
|
759
|
+
priority: this.priority,
|
|
760
|
+
isExclusive: this.isExclusive,
|
|
761
|
+
timestamp: Date.now(),
|
|
762
|
+
metadata
|
|
763
|
+
};
|
|
764
|
+
this.eventBus.emit("recognition:gesture" /* RecognitionGesture */, event);
|
|
765
|
+
}
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
// src/recognition/discrete/TapRecognizer.ts
|
|
769
|
+
var TapRecognizer = class extends BaseRecognizer {
|
|
770
|
+
constructor(eventBus, config = {}) {
|
|
771
|
+
super("tap", eventBus, {
|
|
772
|
+
priority: config.priority ?? 10,
|
|
773
|
+
isExclusive: config.isExclusive ?? false,
|
|
774
|
+
enabled: config.enabled
|
|
775
|
+
});
|
|
776
|
+
this.startTime = null;
|
|
777
|
+
this.startX = null;
|
|
778
|
+
this.startY = null;
|
|
779
|
+
this.maxDuration = config.maxDuration ?? 300;
|
|
780
|
+
this.maxDistance = config.maxDistance ?? 10;
|
|
781
|
+
}
|
|
782
|
+
onProcessedSample(sample) {
|
|
783
|
+
if (!this.enabled) return;
|
|
784
|
+
const { inputEvent } = sample;
|
|
785
|
+
if (inputEvent.inputType !== "touch" /* Touch */) return;
|
|
786
|
+
const touchData = inputEvent.data;
|
|
787
|
+
if (touchData.type !== "tap" /* Tap */ && touchData.type !== "pan" /* Pan */) return;
|
|
788
|
+
if (touchData.type === "tap" /* Tap */) {
|
|
789
|
+
this.transitionToPossible();
|
|
790
|
+
this.transitionToBegan({
|
|
791
|
+
translation: { x: touchData.x, y: touchData.y }
|
|
792
|
+
});
|
|
793
|
+
this.transitionToEnded({
|
|
794
|
+
translation: { x: touchData.x, y: touchData.y }
|
|
795
|
+
});
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (this.startTime === null) {
|
|
799
|
+
this.startTime = inputEvent.timestamp;
|
|
800
|
+
this.startX = touchData.x;
|
|
801
|
+
this.startY = touchData.y;
|
|
802
|
+
this.transitionToPossible();
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
const elapsed = inputEvent.timestamp - this.startTime;
|
|
806
|
+
const dx = touchData.x - (this.startX ?? 0);
|
|
807
|
+
const dy = touchData.y - (this.startY ?? 0);
|
|
808
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
809
|
+
if (distance > this.maxDistance) {
|
|
810
|
+
this.transitionToFailed();
|
|
811
|
+
this.resetState();
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (elapsed > this.maxDuration) {
|
|
815
|
+
this.transitionToFailed();
|
|
816
|
+
this.resetState();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (Math.abs(touchData.velocityX) < 0.01 && Math.abs(touchData.velocityY) < 0.01 && elapsed > 20) {
|
|
820
|
+
this.transitionToBegan({
|
|
821
|
+
translation: { x: this.startX ?? 0, y: this.startY ?? 0 }
|
|
822
|
+
});
|
|
823
|
+
this.transitionToEnded({
|
|
824
|
+
translation: { x: touchData.x, y: touchData.y }
|
|
825
|
+
});
|
|
826
|
+
this.resetState();
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
reset() {
|
|
830
|
+
super.reset();
|
|
831
|
+
this.resetState();
|
|
832
|
+
}
|
|
833
|
+
resetState() {
|
|
834
|
+
this.startTime = null;
|
|
835
|
+
this.startX = null;
|
|
836
|
+
this.startY = null;
|
|
837
|
+
}
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
// src/recognition/discrete/DoubleTapRecognizer.ts
|
|
841
|
+
var DoubleTapRecognizer = class extends BaseRecognizer {
|
|
842
|
+
constructor(eventBus, config = {}) {
|
|
843
|
+
super("double-tap", eventBus, {
|
|
844
|
+
priority: config.priority ?? 5,
|
|
845
|
+
isExclusive: config.isExclusive ?? false,
|
|
846
|
+
enabled: config.enabled
|
|
847
|
+
});
|
|
848
|
+
this.firstTapTime = null;
|
|
849
|
+
this.firstTapX = null;
|
|
850
|
+
this.firstTapY = null;
|
|
851
|
+
this.tapCount = 0;
|
|
852
|
+
this.maxInterval = config.maxInterval ?? 300;
|
|
853
|
+
this.maxDistance = config.maxDistance ?? 30;
|
|
854
|
+
}
|
|
855
|
+
onProcessedSample(sample) {
|
|
856
|
+
if (!this.enabled) return;
|
|
857
|
+
const { inputEvent } = sample;
|
|
858
|
+
if (inputEvent.inputType !== "touch" /* Touch */) return;
|
|
859
|
+
const touchData = inputEvent.data;
|
|
860
|
+
if (touchData.type !== "tap" /* Tap */) return;
|
|
861
|
+
const now = inputEvent.timestamp;
|
|
862
|
+
if (this.tapCount === 0) {
|
|
863
|
+
this.firstTapTime = now;
|
|
864
|
+
this.firstTapX = touchData.x;
|
|
865
|
+
this.firstTapY = touchData.y;
|
|
866
|
+
this.tapCount = 1;
|
|
867
|
+
this.transitionToPossible();
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
if (this.tapCount === 1) {
|
|
871
|
+
const elapsed = now - (this.firstTapTime ?? 0);
|
|
872
|
+
const dx = touchData.x - (this.firstTapX ?? 0);
|
|
873
|
+
const dy = touchData.y - (this.firstTapY ?? 0);
|
|
874
|
+
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
875
|
+
if (elapsed <= this.maxInterval && distance <= this.maxDistance) {
|
|
876
|
+
this.transitionToBegan({
|
|
877
|
+
translation: { x: touchData.x, y: touchData.y }
|
|
878
|
+
});
|
|
879
|
+
this.transitionToEnded({
|
|
880
|
+
translation: { x: touchData.x, y: touchData.y }
|
|
881
|
+
});
|
|
882
|
+
this.resetState();
|
|
883
|
+
} else {
|
|
884
|
+
this.transitionToFailed();
|
|
885
|
+
this.firstTapTime = now;
|
|
886
|
+
this.firstTapX = touchData.x;
|
|
887
|
+
this.firstTapY = touchData.y;
|
|
888
|
+
this.tapCount = 1;
|
|
889
|
+
this.transitionToPossible();
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
reset() {
|
|
894
|
+
super.reset();
|
|
895
|
+
this.resetState();
|
|
896
|
+
}
|
|
897
|
+
resetState() {
|
|
898
|
+
this.firstTapTime = null;
|
|
899
|
+
this.firstTapX = null;
|
|
900
|
+
this.firstTapY = null;
|
|
901
|
+
this.tapCount = 0;
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
// src/recognition/continuous/PanRecognizer.ts
|
|
906
|
+
var PanRecognizer = class extends BaseRecognizer {
|
|
907
|
+
constructor(eventBus, config = {}) {
|
|
908
|
+
super("pan", eventBus, {
|
|
909
|
+
priority: config.priority ?? 50,
|
|
910
|
+
isExclusive: config.isExclusive ?? false,
|
|
911
|
+
enabled: config.enabled
|
|
912
|
+
});
|
|
913
|
+
this.minDistance = config.minDistance ?? 10;
|
|
914
|
+
}
|
|
915
|
+
onProcessedSample(sample) {
|
|
916
|
+
if (!this.enabled) return;
|
|
917
|
+
const { inputEvent } = sample;
|
|
918
|
+
if (inputEvent.inputType !== "touch" /* Touch */) return;
|
|
919
|
+
const touchData = inputEvent.data;
|
|
920
|
+
if (touchData.type !== "pan" /* Pan */) return;
|
|
921
|
+
const translation = {
|
|
922
|
+
x: touchData.translationX,
|
|
923
|
+
y: touchData.translationY
|
|
924
|
+
};
|
|
925
|
+
const distance = Math.sqrt(
|
|
926
|
+
translation.x * translation.x + translation.y * translation.y
|
|
927
|
+
);
|
|
928
|
+
const velocity = {
|
|
929
|
+
x: touchData.velocityX,
|
|
930
|
+
y: touchData.velocityY
|
|
931
|
+
};
|
|
932
|
+
if (this.state === "idle" || this.state === "possible") {
|
|
933
|
+
this.transitionToPossible();
|
|
934
|
+
if (distance >= this.minDistance) {
|
|
935
|
+
this.transitionToBegan({ translation, velocity });
|
|
936
|
+
}
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (this.state === "began" || this.state === "changed") {
|
|
940
|
+
if (Math.abs(touchData.velocityX) < 1e-3 && Math.abs(touchData.velocityY) < 1e-3) {
|
|
941
|
+
this.transitionToEnded({ translation, velocity });
|
|
942
|
+
} else {
|
|
943
|
+
this.transitionToChanged({ translation, velocity });
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
|
|
949
|
+
// src/recognition/continuous/PinchRecognizer.ts
|
|
950
|
+
var PinchRecognizer = class extends BaseRecognizer {
|
|
951
|
+
constructor(eventBus, config = {}) {
|
|
952
|
+
super("pinch", eventBus, {
|
|
953
|
+
priority: config.priority ?? 40,
|
|
954
|
+
isExclusive: config.isExclusive ?? false,
|
|
955
|
+
enabled: config.enabled
|
|
956
|
+
});
|
|
957
|
+
this.minScale = config.minScale ?? 0.05;
|
|
958
|
+
}
|
|
959
|
+
onProcessedSample(sample) {
|
|
960
|
+
if (!this.enabled) return;
|
|
961
|
+
const { inputEvent } = sample;
|
|
962
|
+
if (inputEvent.inputType !== "touch" /* Touch */) return;
|
|
963
|
+
const touchData = inputEvent.data;
|
|
964
|
+
if (touchData.type !== "pinch" /* Pinch */) return;
|
|
965
|
+
const scaleDelta = Math.abs(touchData.scale - 1);
|
|
966
|
+
if (this.state === "idle" || this.state === "possible") {
|
|
967
|
+
this.transitionToPossible();
|
|
968
|
+
if (scaleDelta >= this.minScale) {
|
|
969
|
+
this.transitionToBegan({ scale: touchData.scale });
|
|
970
|
+
}
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
if (this.state === "began" || this.state === "changed") {
|
|
974
|
+
if (touchData.numberOfPointers < 2) {
|
|
975
|
+
this.transitionToEnded({ scale: touchData.scale });
|
|
976
|
+
} else {
|
|
977
|
+
this.transitionToChanged({ scale: touchData.scale });
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
|
|
983
|
+
// src/recognition/continuous/RotationRecognizer.ts
|
|
984
|
+
var RotationRecognizer = class extends BaseRecognizer {
|
|
985
|
+
constructor(eventBus, config = {}) {
|
|
986
|
+
super("rotation", eventBus, {
|
|
987
|
+
priority: config.priority ?? 45,
|
|
988
|
+
isExclusive: config.isExclusive ?? false,
|
|
989
|
+
enabled: config.enabled
|
|
990
|
+
});
|
|
991
|
+
this.minRotation = config.minRotation ?? 0.05;
|
|
992
|
+
}
|
|
993
|
+
onProcessedSample(sample) {
|
|
994
|
+
if (!this.enabled) return;
|
|
995
|
+
const { inputEvent } = sample;
|
|
996
|
+
if (inputEvent.inputType !== "touch" /* Touch */) return;
|
|
997
|
+
const touchData = inputEvent.data;
|
|
998
|
+
if (touchData.type !== "rotation" /* Rotation */) return;
|
|
999
|
+
const rotationAbs = Math.abs(touchData.rotation);
|
|
1000
|
+
if (this.state === "idle" || this.state === "possible") {
|
|
1001
|
+
this.transitionToPossible();
|
|
1002
|
+
if (rotationAbs >= this.minRotation) {
|
|
1003
|
+
this.transitionToBegan({ rotation: touchData.rotation });
|
|
1004
|
+
}
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
if (this.state === "began" || this.state === "changed") {
|
|
1008
|
+
if (touchData.numberOfPointers < 2) {
|
|
1009
|
+
this.transitionToEnded({ rotation: touchData.rotation });
|
|
1010
|
+
} else {
|
|
1011
|
+
this.transitionToChanged({ rotation: touchData.rotation });
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
};
|
|
1016
|
+
|
|
1017
|
+
// src/recognition/spatial/EdgeSwipeRecognizer.ts
|
|
1018
|
+
var EdgeSwipeRecognizer = class extends BaseRecognizer {
|
|
1019
|
+
constructor(eventBus, config) {
|
|
1020
|
+
super(`edge-swipe-${config.edge}`, eventBus, {
|
|
1021
|
+
priority: config.priority ?? 20,
|
|
1022
|
+
isExclusive: config.isExclusive ?? true,
|
|
1023
|
+
enabled: config.enabled
|
|
1024
|
+
});
|
|
1025
|
+
this.startedInEdge = false;
|
|
1026
|
+
this.startX = null;
|
|
1027
|
+
this.startY = null;
|
|
1028
|
+
this.edge = config.edge;
|
|
1029
|
+
this.edgeZoneWidth = config.edgeZoneWidth ?? 30;
|
|
1030
|
+
this.minDistance = config.minDistance ?? 50;
|
|
1031
|
+
this.minVelocity = config.minVelocity ?? 0.3;
|
|
1032
|
+
this.screenWidth = config.screenWidth ?? 400;
|
|
1033
|
+
this.screenHeight = config.screenHeight ?? 800;
|
|
1034
|
+
}
|
|
1035
|
+
onProcessedSample(sample) {
|
|
1036
|
+
if (!this.enabled) return;
|
|
1037
|
+
const { inputEvent } = sample;
|
|
1038
|
+
if (inputEvent.inputType !== "touch" /* Touch */) return;
|
|
1039
|
+
const touchData = inputEvent.data;
|
|
1040
|
+
if (touchData.type !== "pan" /* Pan */) return;
|
|
1041
|
+
if (this.startX === null) {
|
|
1042
|
+
this.startX = touchData.x;
|
|
1043
|
+
this.startY = touchData.y;
|
|
1044
|
+
this.startedInEdge = this.isInEdgeZone(touchData.x, touchData.y);
|
|
1045
|
+
if (this.startedInEdge) {
|
|
1046
|
+
this.transitionToPossible();
|
|
1047
|
+
}
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
if (!this.startedInEdge) return;
|
|
1051
|
+
const swipeDistance = this.getSwipeDistance(touchData);
|
|
1052
|
+
const swipeVelocity = this.getSwipeVelocity(touchData);
|
|
1053
|
+
if (this.state === "possible") {
|
|
1054
|
+
if (swipeDistance >= this.minDistance && swipeVelocity >= this.minVelocity) {
|
|
1055
|
+
this.transitionToBegan({
|
|
1056
|
+
edge: this.edge,
|
|
1057
|
+
translation: {
|
|
1058
|
+
x: touchData.translationX,
|
|
1059
|
+
y: touchData.translationY
|
|
1060
|
+
},
|
|
1061
|
+
velocity: {
|
|
1062
|
+
x: touchData.velocityX,
|
|
1063
|
+
y: touchData.velocityY
|
|
1064
|
+
}
|
|
1065
|
+
});
|
|
1066
|
+
this.transitionToEnded({
|
|
1067
|
+
edge: this.edge,
|
|
1068
|
+
translation: {
|
|
1069
|
+
x: touchData.translationX,
|
|
1070
|
+
y: touchData.translationY
|
|
1071
|
+
},
|
|
1072
|
+
velocity: {
|
|
1073
|
+
x: touchData.velocityX,
|
|
1074
|
+
y: touchData.velocityY
|
|
1075
|
+
}
|
|
1076
|
+
});
|
|
1077
|
+
this.resetState();
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
reset() {
|
|
1082
|
+
super.reset();
|
|
1083
|
+
this.resetState();
|
|
1084
|
+
}
|
|
1085
|
+
/**
|
|
1086
|
+
* Check if a point is within the configured edge zone.
|
|
1087
|
+
*/
|
|
1088
|
+
isInEdgeZone(x, y) {
|
|
1089
|
+
switch (this.edge) {
|
|
1090
|
+
case "left":
|
|
1091
|
+
return x <= this.edgeZoneWidth;
|
|
1092
|
+
case "right":
|
|
1093
|
+
return x >= this.screenWidth - this.edgeZoneWidth;
|
|
1094
|
+
case "top":
|
|
1095
|
+
return y <= this.edgeZoneWidth;
|
|
1096
|
+
case "bottom":
|
|
1097
|
+
return y >= this.screenHeight - this.edgeZoneWidth;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
/**
|
|
1101
|
+
* Get the swipe distance along the expected axis.
|
|
1102
|
+
* Left/right edges → horizontal distance, top/bottom → vertical.
|
|
1103
|
+
*/
|
|
1104
|
+
getSwipeDistance(touchData) {
|
|
1105
|
+
switch (this.edge) {
|
|
1106
|
+
case "left":
|
|
1107
|
+
return touchData.translationX;
|
|
1108
|
+
// positive = swiping right (from left edge)
|
|
1109
|
+
case "right":
|
|
1110
|
+
return -touchData.translationX;
|
|
1111
|
+
// positive = swiping left (from right edge)
|
|
1112
|
+
case "top":
|
|
1113
|
+
return touchData.translationY;
|
|
1114
|
+
// positive = swiping down (from top edge)
|
|
1115
|
+
case "bottom":
|
|
1116
|
+
return -touchData.translationY;
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Get the swipe velocity along the expected axis.
|
|
1121
|
+
*/
|
|
1122
|
+
getSwipeVelocity(touchData) {
|
|
1123
|
+
switch (this.edge) {
|
|
1124
|
+
case "left":
|
|
1125
|
+
return touchData.velocityX;
|
|
1126
|
+
case "right":
|
|
1127
|
+
return -touchData.velocityX;
|
|
1128
|
+
case "top":
|
|
1129
|
+
return touchData.velocityY;
|
|
1130
|
+
case "bottom":
|
|
1131
|
+
return -touchData.velocityY;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
resetState() {
|
|
1135
|
+
this.startX = null;
|
|
1136
|
+
this.startY = null;
|
|
1137
|
+
this.startedInEdge = false;
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
// src/recognition/spatial/CornerRecognizer.ts
|
|
1142
|
+
var CornerRecognizer = class extends BaseRecognizer {
|
|
1143
|
+
constructor(eventBus, config) {
|
|
1144
|
+
super(`corner-${config.corner}`, eventBus, {
|
|
1145
|
+
priority: config.priority ?? 15,
|
|
1146
|
+
isExclusive: config.isExclusive ?? true,
|
|
1147
|
+
enabled: config.enabled
|
|
1148
|
+
});
|
|
1149
|
+
this.startedInCorner = false;
|
|
1150
|
+
this.startX = null;
|
|
1151
|
+
this.startY = null;
|
|
1152
|
+
this.corner = config.corner;
|
|
1153
|
+
this.cornerZoneSize = config.cornerZoneSize ?? 50;
|
|
1154
|
+
this.minDistance = config.minDistance ?? 40;
|
|
1155
|
+
this.screenWidth = config.screenWidth ?? 400;
|
|
1156
|
+
this.screenHeight = config.screenHeight ?? 800;
|
|
1157
|
+
}
|
|
1158
|
+
onProcessedSample(sample) {
|
|
1159
|
+
if (!this.enabled) return;
|
|
1160
|
+
const { inputEvent } = sample;
|
|
1161
|
+
if (inputEvent.inputType !== "touch" /* Touch */) return;
|
|
1162
|
+
const touchData = inputEvent.data;
|
|
1163
|
+
if (touchData.type !== "pan" /* Pan */) return;
|
|
1164
|
+
if (this.startX === null) {
|
|
1165
|
+
this.startX = touchData.x;
|
|
1166
|
+
this.startY = touchData.y;
|
|
1167
|
+
this.startedInCorner = this.isInCornerZone(touchData.x, touchData.y);
|
|
1168
|
+
if (this.startedInCorner) {
|
|
1169
|
+
this.transitionToPossible();
|
|
1170
|
+
}
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if (!this.startedInCorner) return;
|
|
1174
|
+
const distance = Math.sqrt(
|
|
1175
|
+
touchData.translationX ** 2 + touchData.translationY ** 2
|
|
1176
|
+
);
|
|
1177
|
+
if (this.state === "possible" && distance >= this.minDistance) {
|
|
1178
|
+
this.transitionToBegan({
|
|
1179
|
+
translation: {
|
|
1180
|
+
x: touchData.translationX,
|
|
1181
|
+
y: touchData.translationY
|
|
1182
|
+
}
|
|
1183
|
+
});
|
|
1184
|
+
this.transitionToEnded({
|
|
1185
|
+
translation: {
|
|
1186
|
+
x: touchData.translationX,
|
|
1187
|
+
y: touchData.translationY
|
|
1188
|
+
}
|
|
1189
|
+
});
|
|
1190
|
+
this.resetState();
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
reset() {
|
|
1194
|
+
super.reset();
|
|
1195
|
+
this.resetState();
|
|
1196
|
+
}
|
|
1197
|
+
isInCornerZone(x, y) {
|
|
1198
|
+
const zone = this.cornerZoneSize;
|
|
1199
|
+
switch (this.corner) {
|
|
1200
|
+
case "top-left":
|
|
1201
|
+
return x <= zone && y <= zone;
|
|
1202
|
+
case "top-right":
|
|
1203
|
+
return x >= this.screenWidth - zone && y <= zone;
|
|
1204
|
+
case "bottom-left":
|
|
1205
|
+
return x <= zone && y >= this.screenHeight - zone;
|
|
1206
|
+
case "bottom-right":
|
|
1207
|
+
return x >= this.screenWidth - zone && y >= this.screenHeight - zone;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
resetState() {
|
|
1211
|
+
this.startX = null;
|
|
1212
|
+
this.startY = null;
|
|
1213
|
+
this.startedInCorner = false;
|
|
1214
|
+
}
|
|
1215
|
+
};
|
|
1216
|
+
|
|
1217
|
+
// src/recognition/sensor/ShakeRecognizer.ts
|
|
1218
|
+
var ShakeRecognizer = class extends BaseRecognizer {
|
|
1219
|
+
constructor(eventBus, config = {}) {
|
|
1220
|
+
super("shake", eventBus, {
|
|
1221
|
+
priority: config.priority ?? 30,
|
|
1222
|
+
isExclusive: config.isExclusive ?? false,
|
|
1223
|
+
enabled: config.enabled
|
|
1224
|
+
});
|
|
1225
|
+
this.aboveThresholdCount = 0;
|
|
1226
|
+
this.lastTriggerTime = 0;
|
|
1227
|
+
this.threshold = config.threshold ?? 1.5;
|
|
1228
|
+
this.consecutiveSamples = config.consecutiveSamples ?? 2;
|
|
1229
|
+
this.cooldownMs = config.cooldownMs ?? 1e3;
|
|
1230
|
+
}
|
|
1231
|
+
onProcessedSample(sample) {
|
|
1232
|
+
if (!this.enabled) return;
|
|
1233
|
+
const { inputEvent } = sample;
|
|
1234
|
+
if (inputEvent.inputType !== "sensor" /* Sensor */) return;
|
|
1235
|
+
const sensorData = inputEvent.data;
|
|
1236
|
+
if (sensorData.type !== "accelerometer" /* Accelerometer */) return;
|
|
1237
|
+
const { x, y, z } = sample.filtered;
|
|
1238
|
+
const magnitude = Math.sqrt(x * x + y * y + z * z);
|
|
1239
|
+
if (magnitude >= this.threshold) {
|
|
1240
|
+
this.aboveThresholdCount++;
|
|
1241
|
+
if (this.aboveThresholdCount >= this.consecutiveSamples) {
|
|
1242
|
+
const now = Date.now();
|
|
1243
|
+
if (now - this.lastTriggerTime < this.cooldownMs) {
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
1246
|
+
this.lastTriggerTime = now;
|
|
1247
|
+
this.aboveThresholdCount = 0;
|
|
1248
|
+
this.transitionToPossible();
|
|
1249
|
+
this.transitionToBegan({ magnitude });
|
|
1250
|
+
this.transitionToEnded({ magnitude });
|
|
1251
|
+
}
|
|
1252
|
+
} else {
|
|
1253
|
+
this.aboveThresholdCount = 0;
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
reset() {
|
|
1257
|
+
super.reset();
|
|
1258
|
+
this.aboveThresholdCount = 0;
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
|
|
1262
|
+
// src/recognition/sensor/TiltRecognizer.ts
|
|
1263
|
+
var TiltRecognizer = class extends BaseRecognizer {
|
|
1264
|
+
constructor(eventBus, config = {}) {
|
|
1265
|
+
super("tilt", eventBus, {
|
|
1266
|
+
priority: config.priority ?? 35,
|
|
1267
|
+
isExclusive: config.isExclusive ?? false,
|
|
1268
|
+
enabled: config.enabled
|
|
1269
|
+
});
|
|
1270
|
+
this.lastTriggerTime = 0;
|
|
1271
|
+
this.tiltThreshold = config.tiltThreshold ?? 30;
|
|
1272
|
+
this.cooldownMs = config.cooldownMs ?? 500;
|
|
1273
|
+
}
|
|
1274
|
+
onProcessedSample(sample) {
|
|
1275
|
+
if (!this.enabled) return;
|
|
1276
|
+
const { inputEvent } = sample;
|
|
1277
|
+
if (inputEvent.inputType !== "sensor" /* Sensor */) return;
|
|
1278
|
+
const sensorData = inputEvent.data;
|
|
1279
|
+
if (sensorData.type !== "accelerometer" /* Accelerometer */) return;
|
|
1280
|
+
const { x, y, z } = sensorData;
|
|
1281
|
+
const pitch = Math.atan2(y, Math.sqrt(x * x + z * z)) * (180 / Math.PI);
|
|
1282
|
+
const roll = Math.atan2(x, Math.sqrt(y * y + z * z)) * (180 / Math.PI);
|
|
1283
|
+
const maxTilt = Math.max(Math.abs(pitch), Math.abs(roll));
|
|
1284
|
+
if (maxTilt >= this.tiltThreshold) {
|
|
1285
|
+
const now = Date.now();
|
|
1286
|
+
if (now - this.lastTriggerTime < this.cooldownMs) {
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
this.lastTriggerTime = now;
|
|
1290
|
+
this.transitionToPossible();
|
|
1291
|
+
this.transitionToBegan({
|
|
1292
|
+
tilt: { pitch, roll },
|
|
1293
|
+
magnitude: maxTilt
|
|
1294
|
+
});
|
|
1295
|
+
this.transitionToEnded({
|
|
1296
|
+
tilt: { pitch, roll },
|
|
1297
|
+
magnitude: maxTilt
|
|
1298
|
+
});
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
};
|
|
1302
|
+
|
|
1303
|
+
// src/recognition/sensor/WristFlickRecognizer.ts
|
|
1304
|
+
var WristFlickRecognizer = class extends BaseRecognizer {
|
|
1305
|
+
constructor(eventBus, config = {}) {
|
|
1306
|
+
super("wrist-flick", eventBus, {
|
|
1307
|
+
priority: config.priority ?? 25,
|
|
1308
|
+
isExclusive: config.isExclusive ?? false,
|
|
1309
|
+
enabled: config.enabled
|
|
1310
|
+
});
|
|
1311
|
+
this.lastTriggerTime = 0;
|
|
1312
|
+
this.angularVelocityThreshold = (config.angularVelocityThreshold ?? 150) * Math.PI / 180;
|
|
1313
|
+
this.cooldownMs = config.cooldownMs ?? 800;
|
|
1314
|
+
}
|
|
1315
|
+
onProcessedSample(sample) {
|
|
1316
|
+
if (!this.enabled) return;
|
|
1317
|
+
const { inputEvent } = sample;
|
|
1318
|
+
if (inputEvent.inputType !== "sensor" /* Sensor */) return;
|
|
1319
|
+
const sensorData = inputEvent.data;
|
|
1320
|
+
if (sensorData.type !== "gyroscope" /* Gyroscope */) return;
|
|
1321
|
+
const { x, y, z } = sample.filtered;
|
|
1322
|
+
const angularVelocity = Math.sqrt(x * x + y * y + z * z);
|
|
1323
|
+
if (angularVelocity >= this.angularVelocityThreshold) {
|
|
1324
|
+
const now = Date.now();
|
|
1325
|
+
if (now - this.lastTriggerTime < this.cooldownMs) {
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
this.lastTriggerTime = now;
|
|
1329
|
+
this.transitionToPossible();
|
|
1330
|
+
this.transitionToBegan({
|
|
1331
|
+
magnitude: angularVelocity * (180 / Math.PI)
|
|
1332
|
+
// Convert back to deg/s for metadata
|
|
1333
|
+
});
|
|
1334
|
+
this.transitionToEnded({
|
|
1335
|
+
magnitude: angularVelocity * (180 / Math.PI)
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
// src/recognition/sequence/SequenceRecognizer.ts
|
|
1342
|
+
var SequenceRecognizer = class extends BaseRecognizer {
|
|
1343
|
+
constructor(eventBus, config) {
|
|
1344
|
+
const sequenceName = `sequence:${config.sequence.join(">")}`;
|
|
1345
|
+
super(sequenceName, eventBus, {
|
|
1346
|
+
priority: config.priority ?? 1,
|
|
1347
|
+
isExclusive: config.isExclusive ?? false,
|
|
1348
|
+
enabled: config.enabled
|
|
1349
|
+
});
|
|
1350
|
+
this.currentIndex = 0;
|
|
1351
|
+
this.lastStepTime = 0;
|
|
1352
|
+
this.unsubscribe = null;
|
|
1353
|
+
this.sequence = config.sequence;
|
|
1354
|
+
this.timeoutMs = config.timeoutMs ?? 800;
|
|
1355
|
+
this.subscribeToGestures();
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* SequenceRecognizer doesn't use ProcessedSample — it listens
|
|
1359
|
+
* to GestureEvent objects on the EventBus instead.
|
|
1360
|
+
*/
|
|
1361
|
+
onProcessedSample(_sample) {
|
|
1362
|
+
}
|
|
1363
|
+
reset() {
|
|
1364
|
+
super.reset();
|
|
1365
|
+
this.currentIndex = 0;
|
|
1366
|
+
this.lastStepTime = 0;
|
|
1367
|
+
}
|
|
1368
|
+
dispose() {
|
|
1369
|
+
if (this.unsubscribe) {
|
|
1370
|
+
this.unsubscribe();
|
|
1371
|
+
this.unsubscribe = null;
|
|
1372
|
+
}
|
|
1373
|
+
super.dispose();
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Subscribe to the RecognitionGesture channel to listen for
|
|
1377
|
+
* completed gestures and advance the sequence.
|
|
1378
|
+
*/
|
|
1379
|
+
subscribeToGestures() {
|
|
1380
|
+
this.unsubscribe = this.eventBus.on(
|
|
1381
|
+
"recognition:gesture" /* RecognitionGesture */,
|
|
1382
|
+
(event) => {
|
|
1383
|
+
if (!this.enabled) return;
|
|
1384
|
+
if (event.state !== "ended" /* Ended */) return;
|
|
1385
|
+
if (event.name.startsWith("sequence:")) return;
|
|
1386
|
+
const now = Date.now();
|
|
1387
|
+
if (this.currentIndex > 0 && now - this.lastStepTime > this.timeoutMs) {
|
|
1388
|
+
this.reset();
|
|
1389
|
+
}
|
|
1390
|
+
const expectedName = this.sequence[this.currentIndex];
|
|
1391
|
+
if (event.name === expectedName) {
|
|
1392
|
+
this.currentIndex++;
|
|
1393
|
+
this.lastStepTime = now;
|
|
1394
|
+
if (this.currentIndex >= this.sequence.length) {
|
|
1395
|
+
this.transitionToPossible();
|
|
1396
|
+
this.transitionToBegan({});
|
|
1397
|
+
this.transitionToEnded({});
|
|
1398
|
+
this.currentIndex = 0;
|
|
1399
|
+
this.lastStepTime = 0;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
);
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
|
|
1407
|
+
// src/recognition/symbolic/SymbolRecognizer.ts
|
|
1408
|
+
function generateCircleTemplate(n = 64) {
|
|
1409
|
+
const points = [];
|
|
1410
|
+
for (let i = 0; i < n; i++) {
|
|
1411
|
+
const angle = 2 * Math.PI * i / n;
|
|
1412
|
+
points.push({ x: Math.cos(angle), y: Math.sin(angle) });
|
|
1413
|
+
}
|
|
1414
|
+
return points;
|
|
1415
|
+
}
|
|
1416
|
+
function generateTriangleTemplate() {
|
|
1417
|
+
const pts = [];
|
|
1418
|
+
const vertices = [
|
|
1419
|
+
{ x: 0.5, y: 0 },
|
|
1420
|
+
{ x: 1, y: 1 },
|
|
1421
|
+
{ x: 0, y: 1 }
|
|
1422
|
+
];
|
|
1423
|
+
for (let side = 0; side < 3; side++) {
|
|
1424
|
+
const from = vertices[side];
|
|
1425
|
+
const to = vertices[(side + 1) % 3];
|
|
1426
|
+
for (let i = 0; i < 21; i++) {
|
|
1427
|
+
const t = i / 20;
|
|
1428
|
+
pts.push({ x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t });
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
return pts;
|
|
1432
|
+
}
|
|
1433
|
+
function generateCheckTemplate() {
|
|
1434
|
+
const pts = [];
|
|
1435
|
+
for (let i = 0; i <= 20; i++) {
|
|
1436
|
+
const t = i / 20;
|
|
1437
|
+
pts.push({ x: t * 0.4, y: 0.3 + t * 0.7 });
|
|
1438
|
+
}
|
|
1439
|
+
for (let i = 0; i <= 20; i++) {
|
|
1440
|
+
const t = i / 20;
|
|
1441
|
+
pts.push({ x: 0.4 + t * 0.6, y: 1 - t * 1 });
|
|
1442
|
+
}
|
|
1443
|
+
return pts;
|
|
1444
|
+
}
|
|
1445
|
+
var DEFAULT_TEMPLATES = {
|
|
1446
|
+
circle: generateCircleTemplate(),
|
|
1447
|
+
triangle: generateTriangleTemplate(),
|
|
1448
|
+
check: generateCheckTemplate()
|
|
1449
|
+
};
|
|
1450
|
+
var SymbolRecognizer = class extends BaseRecognizer {
|
|
1451
|
+
constructor(eventBus, config = {}) {
|
|
1452
|
+
super("symbol", eventBus, {
|
|
1453
|
+
priority: config.priority ?? 60,
|
|
1454
|
+
isExclusive: config.isExclusive ?? false,
|
|
1455
|
+
enabled: config.enabled
|
|
1456
|
+
});
|
|
1457
|
+
this.currentPath = [];
|
|
1458
|
+
this.isDrawing = false;
|
|
1459
|
+
this.resampleCount = 64;
|
|
1460
|
+
this.squareSize = 250;
|
|
1461
|
+
this.templates = config.templates ?? DEFAULT_TEMPLATES;
|
|
1462
|
+
this.minConfidence = config.minConfidence ?? 0.7;
|
|
1463
|
+
}
|
|
1464
|
+
onProcessedSample(sample) {
|
|
1465
|
+
if (!this.enabled) return;
|
|
1466
|
+
const { inputEvent } = sample;
|
|
1467
|
+
if (inputEvent.inputType !== "touch" /* Touch */) return;
|
|
1468
|
+
const touchData = inputEvent.data;
|
|
1469
|
+
if (touchData.type !== "pan" /* Pan */) return;
|
|
1470
|
+
const velocity = Math.sqrt(
|
|
1471
|
+
touchData.velocityX ** 2 + touchData.velocityY ** 2
|
|
1472
|
+
);
|
|
1473
|
+
if (!this.isDrawing) {
|
|
1474
|
+
this.isDrawing = true;
|
|
1475
|
+
this.currentPath = [];
|
|
1476
|
+
this.transitionToPossible();
|
|
1477
|
+
}
|
|
1478
|
+
this.currentPath.push({ x: touchData.x, y: touchData.y });
|
|
1479
|
+
if (velocity < 0.01 && this.currentPath.length > 10) {
|
|
1480
|
+
this.isDrawing = false;
|
|
1481
|
+
this.recognize();
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
reset() {
|
|
1485
|
+
super.reset();
|
|
1486
|
+
this.currentPath = [];
|
|
1487
|
+
this.isDrawing = false;
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Run the $1 recognizer against collected path points.
|
|
1491
|
+
*/
|
|
1492
|
+
recognize() {
|
|
1493
|
+
if (this.currentPath.length < 10) {
|
|
1494
|
+
this.transitionToFailed();
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
const resampled = this.resample(this.currentPath, this.resampleCount);
|
|
1498
|
+
const centroid = this.getCentroid(resampled);
|
|
1499
|
+
const angle = Math.atan2(centroid.y - resampled[0].y, centroid.x - resampled[0].x);
|
|
1500
|
+
const rotated = this.rotateBy(resampled, -angle);
|
|
1501
|
+
const scaled = this.scaleTo(rotated, this.squareSize);
|
|
1502
|
+
const translated = this.translateToOrigin(scaled);
|
|
1503
|
+
let bestMatch = "";
|
|
1504
|
+
let bestScore = 0;
|
|
1505
|
+
for (const [name, templatePoints] of Object.entries(this.templates)) {
|
|
1506
|
+
const tResampled = this.resample(templatePoints, this.resampleCount);
|
|
1507
|
+
const tCentroid = this.getCentroid(tResampled);
|
|
1508
|
+
const tAngle = Math.atan2(tCentroid.y - tResampled[0].y, tCentroid.x - tResampled[0].x);
|
|
1509
|
+
const tRotated = this.rotateBy(tResampled, -tAngle);
|
|
1510
|
+
const tScaled = this.scaleTo(tRotated, this.squareSize);
|
|
1511
|
+
const tTranslated = this.translateToOrigin(tScaled);
|
|
1512
|
+
const distance = this.pathDistance(translated, tTranslated);
|
|
1513
|
+
const halfDiagonal = 0.5 * Math.sqrt(2) * this.squareSize;
|
|
1514
|
+
const score = 1 - distance / halfDiagonal;
|
|
1515
|
+
if (score > bestScore) {
|
|
1516
|
+
bestScore = score;
|
|
1517
|
+
bestMatch = name;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
if (bestScore >= this.minConfidence) {
|
|
1521
|
+
this.transitionToBegan({
|
|
1522
|
+
symbol: bestMatch,
|
|
1523
|
+
confidence: bestScore
|
|
1524
|
+
});
|
|
1525
|
+
this.transitionToEnded({
|
|
1526
|
+
symbol: bestMatch,
|
|
1527
|
+
confidence: bestScore
|
|
1528
|
+
});
|
|
1529
|
+
} else {
|
|
1530
|
+
this.transitionToFailed();
|
|
1531
|
+
}
|
|
1532
|
+
this.currentPath = [];
|
|
1533
|
+
}
|
|
1534
|
+
// ─── $1 Unistroke helper functions ────────────────────────────────
|
|
1535
|
+
resample(points, n) {
|
|
1536
|
+
const totalLength = this.pathLength(points);
|
|
1537
|
+
const interval = totalLength / (n - 1);
|
|
1538
|
+
const resampled = [points[0]];
|
|
1539
|
+
let accumulated = 0;
|
|
1540
|
+
for (let i = 1; i < points.length; i++) {
|
|
1541
|
+
const d = this.distance(points[i - 1], points[i]);
|
|
1542
|
+
if (accumulated + d >= interval) {
|
|
1543
|
+
const t = (interval - accumulated) / d;
|
|
1544
|
+
const newPoint = {
|
|
1545
|
+
x: points[i - 1].x + t * (points[i].x - points[i - 1].x),
|
|
1546
|
+
y: points[i - 1].y + t * (points[i].y - points[i - 1].y)
|
|
1547
|
+
};
|
|
1548
|
+
resampled.push(newPoint);
|
|
1549
|
+
points.splice(i, 0, newPoint);
|
|
1550
|
+
accumulated = 0;
|
|
1551
|
+
} else {
|
|
1552
|
+
accumulated += d;
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
while (resampled.length < n) {
|
|
1556
|
+
resampled.push(points[points.length - 1]);
|
|
1557
|
+
}
|
|
1558
|
+
return resampled.slice(0, n);
|
|
1559
|
+
}
|
|
1560
|
+
getCentroid(points) {
|
|
1561
|
+
let sumX = 0, sumY = 0;
|
|
1562
|
+
for (const p of points) {
|
|
1563
|
+
sumX += p.x;
|
|
1564
|
+
sumY += p.y;
|
|
1565
|
+
}
|
|
1566
|
+
return { x: sumX / points.length, y: sumY / points.length };
|
|
1567
|
+
}
|
|
1568
|
+
rotateBy(points, angle) {
|
|
1569
|
+
const centroid = this.getCentroid(points);
|
|
1570
|
+
const cos = Math.cos(angle);
|
|
1571
|
+
const sin = Math.sin(angle);
|
|
1572
|
+
return points.map((p) => ({
|
|
1573
|
+
x: (p.x - centroid.x) * cos - (p.y - centroid.y) * sin + centroid.x,
|
|
1574
|
+
y: (p.x - centroid.x) * sin + (p.y - centroid.y) * cos + centroid.y
|
|
1575
|
+
}));
|
|
1576
|
+
}
|
|
1577
|
+
scaleTo(points, size) {
|
|
1578
|
+
const bb = this.boundingBox(points);
|
|
1579
|
+
const w = bb.maxX - bb.minX;
|
|
1580
|
+
const h = bb.maxY - bb.minY;
|
|
1581
|
+
if (w === 0 || h === 0) return points;
|
|
1582
|
+
return points.map((p) => ({
|
|
1583
|
+
x: p.x * size / w,
|
|
1584
|
+
y: p.y * size / h
|
|
1585
|
+
}));
|
|
1586
|
+
}
|
|
1587
|
+
translateToOrigin(points) {
|
|
1588
|
+
const centroid = this.getCentroid(points);
|
|
1589
|
+
return points.map((p) => ({
|
|
1590
|
+
x: p.x - centroid.x,
|
|
1591
|
+
y: p.y - centroid.y
|
|
1592
|
+
}));
|
|
1593
|
+
}
|
|
1594
|
+
boundingBox(points) {
|
|
1595
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
1596
|
+
for (const p of points) {
|
|
1597
|
+
minX = Math.min(minX, p.x);
|
|
1598
|
+
minY = Math.min(minY, p.y);
|
|
1599
|
+
maxX = Math.max(maxX, p.x);
|
|
1600
|
+
maxY = Math.max(maxY, p.y);
|
|
1601
|
+
}
|
|
1602
|
+
return { minX, minY, maxX, maxY };
|
|
1603
|
+
}
|
|
1604
|
+
pathDistance(a, b) {
|
|
1605
|
+
const len = Math.min(a.length, b.length);
|
|
1606
|
+
let total = 0;
|
|
1607
|
+
for (let i = 0; i < len; i++) {
|
|
1608
|
+
total += this.distance(a[i], b[i]);
|
|
1609
|
+
}
|
|
1610
|
+
return total / len;
|
|
1611
|
+
}
|
|
1612
|
+
pathLength(points) {
|
|
1613
|
+
let total = 0;
|
|
1614
|
+
for (let i = 1; i < points.length; i++) {
|
|
1615
|
+
total += this.distance(points[i - 1], points[i]);
|
|
1616
|
+
}
|
|
1617
|
+
return total;
|
|
1618
|
+
}
|
|
1619
|
+
distance(a, b) {
|
|
1620
|
+
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2);
|
|
1621
|
+
}
|
|
1622
|
+
};
|
|
1623
|
+
|
|
1624
|
+
// src/conflict/GesturePriorityQueue.ts
|
|
1625
|
+
var GesturePriorityQueue = class {
|
|
1626
|
+
constructor() {
|
|
1627
|
+
this.heap = [];
|
|
1628
|
+
}
|
|
1629
|
+
get size() {
|
|
1630
|
+
return this.heap.length;
|
|
1631
|
+
}
|
|
1632
|
+
isEmpty() {
|
|
1633
|
+
return this.heap.length === 0;
|
|
1634
|
+
}
|
|
1635
|
+
/** Insert a gesture event into the queue. O(log n). */
|
|
1636
|
+
insert(event) {
|
|
1637
|
+
this.heap.push(event);
|
|
1638
|
+
this.bubbleUp(this.heap.length - 1);
|
|
1639
|
+
}
|
|
1640
|
+
/** Extract and return the highest-priority (lowest priority number) event. O(log n). */
|
|
1641
|
+
extractMin() {
|
|
1642
|
+
if (this.heap.length === 0) return null;
|
|
1643
|
+
if (this.heap.length === 1) return this.heap.pop();
|
|
1644
|
+
const min = this.heap[0];
|
|
1645
|
+
this.heap[0] = this.heap.pop();
|
|
1646
|
+
this.bubbleDown(0);
|
|
1647
|
+
return min;
|
|
1648
|
+
}
|
|
1649
|
+
/** Peek at the highest-priority event without removing it. O(1). */
|
|
1650
|
+
peek() {
|
|
1651
|
+
return this.heap.length > 0 ? this.heap[0] : null;
|
|
1652
|
+
}
|
|
1653
|
+
/** Remove all events from the queue. */
|
|
1654
|
+
clear() {
|
|
1655
|
+
this.heap = [];
|
|
1656
|
+
}
|
|
1657
|
+
/** Drain the queue, returning all events in priority order. */
|
|
1658
|
+
drainAll() {
|
|
1659
|
+
const result = [];
|
|
1660
|
+
while (!this.isEmpty()) {
|
|
1661
|
+
result.push(this.extractMin());
|
|
1662
|
+
}
|
|
1663
|
+
return result;
|
|
1664
|
+
}
|
|
1665
|
+
bubbleUp(index) {
|
|
1666
|
+
while (index > 0) {
|
|
1667
|
+
const parentIndex = Math.floor((index - 1) / 2);
|
|
1668
|
+
if (this.heap[parentIndex].priority <= this.heap[index].priority) break;
|
|
1669
|
+
[this.heap[parentIndex], this.heap[index]] = [this.heap[index], this.heap[parentIndex]];
|
|
1670
|
+
index = parentIndex;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
bubbleDown(index) {
|
|
1674
|
+
const length = this.heap.length;
|
|
1675
|
+
while (true) {
|
|
1676
|
+
let smallest = index;
|
|
1677
|
+
const left = 2 * index + 1;
|
|
1678
|
+
const right = 2 * index + 2;
|
|
1679
|
+
if (left < length && this.heap[left].priority < this.heap[smallest].priority) {
|
|
1680
|
+
smallest = left;
|
|
1681
|
+
}
|
|
1682
|
+
if (right < length && this.heap[right].priority < this.heap[smallest].priority) {
|
|
1683
|
+
smallest = right;
|
|
1684
|
+
}
|
|
1685
|
+
if (smallest === index) break;
|
|
1686
|
+
[this.heap[smallest], this.heap[index]] = [this.heap[index], this.heap[smallest]];
|
|
1687
|
+
index = smallest;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
};
|
|
1691
|
+
|
|
1692
|
+
// src/conflict/LockManager.ts
|
|
1693
|
+
var LockManager = class {
|
|
1694
|
+
constructor() {
|
|
1695
|
+
/** Active locks: gestureName → priority */
|
|
1696
|
+
this.locks = /* @__PURE__ */ new Map();
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* Acquire an exclusive lock for a gesture.
|
|
1700
|
+
*
|
|
1701
|
+
* @param gestureName - The gesture acquiring the lock
|
|
1702
|
+
* @param priority - The priority level of the lock (lower = higher priority)
|
|
1703
|
+
* @returns true if the lock was acquired
|
|
1704
|
+
*/
|
|
1705
|
+
acquireLock(gestureName, priority) {
|
|
1706
|
+
this.locks.set(gestureName, priority);
|
|
1707
|
+
return true;
|
|
1708
|
+
}
|
|
1709
|
+
/**
|
|
1710
|
+
* Release the lock held by a gesture.
|
|
1711
|
+
*/
|
|
1712
|
+
releaseLock(gestureName) {
|
|
1713
|
+
this.locks.delete(gestureName);
|
|
1714
|
+
}
|
|
1715
|
+
/**
|
|
1716
|
+
* Check if a gesture with the given priority is blocked by any active lock.
|
|
1717
|
+
* A gesture is blocked if an active lock has higher or equal priority
|
|
1718
|
+
* (lower or equal priority number) and is not the same gesture.
|
|
1719
|
+
*
|
|
1720
|
+
* @param gestureName - The gesture to check
|
|
1721
|
+
* @param priority - The priority of the gesture to check
|
|
1722
|
+
* @returns true if the gesture is blocked
|
|
1723
|
+
*/
|
|
1724
|
+
isLocked(gestureName, priority) {
|
|
1725
|
+
for (const [lockName, lockPriority] of this.locks) {
|
|
1726
|
+
if (lockName !== gestureName && lockPriority <= priority) {
|
|
1727
|
+
return true;
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
return false;
|
|
1731
|
+
}
|
|
1732
|
+
/**
|
|
1733
|
+
* Check if a specific gesture holds a lock.
|
|
1734
|
+
*/
|
|
1735
|
+
hasLock(gestureName) {
|
|
1736
|
+
return this.locks.has(gestureName);
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* Clear all locks. Called during engine reset.
|
|
1740
|
+
*/
|
|
1741
|
+
clearAll() {
|
|
1742
|
+
this.locks.clear();
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* Get the number of active locks.
|
|
1746
|
+
*/
|
|
1747
|
+
get activeLockCount() {
|
|
1748
|
+
return this.locks.size;
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
|
|
1752
|
+
// src/conflict/ConflictResolver.ts
|
|
1753
|
+
var ConflictResolver = class {
|
|
1754
|
+
constructor(eventBus) {
|
|
1755
|
+
this.unsubscribe = null;
|
|
1756
|
+
// Buffer for batching events within a single tick
|
|
1757
|
+
this.pendingEvents = [];
|
|
1758
|
+
this.processingScheduled = false;
|
|
1759
|
+
this.eventBus = eventBus;
|
|
1760
|
+
this.priorityQueue = new GesturePriorityQueue();
|
|
1761
|
+
this.lockManager = new LockManager();
|
|
1762
|
+
}
|
|
1763
|
+
/**
|
|
1764
|
+
* Start listening for gesture events and resolving conflicts.
|
|
1765
|
+
*/
|
|
1766
|
+
start() {
|
|
1767
|
+
this.unsubscribe = this.eventBus.on(
|
|
1768
|
+
"recognition:gesture" /* RecognitionGesture */,
|
|
1769
|
+
(event) => {
|
|
1770
|
+
this.pendingEvents.push(event);
|
|
1771
|
+
this.scheduleProcessing();
|
|
1772
|
+
}
|
|
1773
|
+
);
|
|
1774
|
+
}
|
|
1775
|
+
/**
|
|
1776
|
+
* Stop listening and clear all state.
|
|
1777
|
+
*/
|
|
1778
|
+
stop() {
|
|
1779
|
+
if (this.unsubscribe) {
|
|
1780
|
+
this.unsubscribe();
|
|
1781
|
+
this.unsubscribe = null;
|
|
1782
|
+
}
|
|
1783
|
+
this.priorityQueue.clear();
|
|
1784
|
+
this.lockManager.clearAll();
|
|
1785
|
+
this.pendingEvents = [];
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* Schedule conflict resolution for the next microtask.
|
|
1789
|
+
* This batches events that arrive in the same tick.
|
|
1790
|
+
*/
|
|
1791
|
+
scheduleProcessing() {
|
|
1792
|
+
if (this.processingScheduled) return;
|
|
1793
|
+
this.processingScheduled = true;
|
|
1794
|
+
Promise.resolve().then(() => {
|
|
1795
|
+
this.processingScheduled = false;
|
|
1796
|
+
this.processEvents();
|
|
1797
|
+
});
|
|
1798
|
+
}
|
|
1799
|
+
/**
|
|
1800
|
+
* Process all pending events through priority queue and lock rules.
|
|
1801
|
+
*/
|
|
1802
|
+
processEvents() {
|
|
1803
|
+
for (const event of this.pendingEvents) {
|
|
1804
|
+
this.priorityQueue.insert(event);
|
|
1805
|
+
}
|
|
1806
|
+
this.pendingEvents = [];
|
|
1807
|
+
const sortedEvents = this.priorityQueue.drainAll();
|
|
1808
|
+
for (const event of sortedEvents) {
|
|
1809
|
+
if (event.isExclusive) {
|
|
1810
|
+
if (event.state === "began" /* Began */) {
|
|
1811
|
+
this.lockManager.acquireLock(event.name, event.priority);
|
|
1812
|
+
} else if (event.state === "ended" /* Ended */ || event.state === "cancelled" /* Cancelled */ || event.state === "failed" /* Failed */) {
|
|
1813
|
+
this.lockManager.releaseLock(event.name);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
if (this.lockManager.isLocked(event.name, event.priority)) {
|
|
1817
|
+
continue;
|
|
1818
|
+
}
|
|
1819
|
+
this.eventBus.emit("conflict:resolved" /* ConflictResolved */, event);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
/** Expose lock manager for testing */
|
|
1823
|
+
getLockManager() {
|
|
1824
|
+
return this.lockManager;
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
|
|
1828
|
+
// src/actions/ActionDispatcher.ts
|
|
1829
|
+
var ActionDispatcher = class {
|
|
1830
|
+
constructor(eventBus) {
|
|
1831
|
+
this.actionMap = /* @__PURE__ */ new Map();
|
|
1832
|
+
this.unsubscribe = null;
|
|
1833
|
+
this.eventBus = eventBus;
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Start listening for resolved gesture events.
|
|
1837
|
+
*/
|
|
1838
|
+
start() {
|
|
1839
|
+
this.unsubscribe = this.eventBus.on(
|
|
1840
|
+
"conflict:resolved" /* ConflictResolved */,
|
|
1841
|
+
(event) => {
|
|
1842
|
+
this.dispatch(event);
|
|
1843
|
+
}
|
|
1844
|
+
);
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Stop listening.
|
|
1848
|
+
*/
|
|
1849
|
+
stop() {
|
|
1850
|
+
if (this.unsubscribe) {
|
|
1851
|
+
this.unsubscribe();
|
|
1852
|
+
this.unsubscribe = null;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Register an action for a gesture name.
|
|
1857
|
+
*/
|
|
1858
|
+
registerAction(gestureName, action) {
|
|
1859
|
+
if (!this.actionMap.has(gestureName)) {
|
|
1860
|
+
this.actionMap.set(gestureName, []);
|
|
1861
|
+
}
|
|
1862
|
+
this.actionMap.get(gestureName).push(action);
|
|
1863
|
+
}
|
|
1864
|
+
/**
|
|
1865
|
+
* Unregister a specific action from a gesture.
|
|
1866
|
+
*/
|
|
1867
|
+
unregisterAction(gestureName, actionId) {
|
|
1868
|
+
const actions = this.actionMap.get(gestureName);
|
|
1869
|
+
if (actions) {
|
|
1870
|
+
const filtered = actions.filter((a) => a.actionId !== actionId);
|
|
1871
|
+
if (filtered.length > 0) {
|
|
1872
|
+
this.actionMap.set(gestureName, filtered);
|
|
1873
|
+
} else {
|
|
1874
|
+
this.actionMap.delete(gestureName);
|
|
1875
|
+
}
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
/**
|
|
1879
|
+
* Clear all registered actions.
|
|
1880
|
+
*/
|
|
1881
|
+
clearActions() {
|
|
1882
|
+
this.actionMap.clear();
|
|
1883
|
+
}
|
|
1884
|
+
/**
|
|
1885
|
+
* Dispatch a gesture event to all matching registered actions.
|
|
1886
|
+
*/
|
|
1887
|
+
dispatch(event) {
|
|
1888
|
+
const actions = this.actionMap.get(event.name);
|
|
1889
|
+
if (!actions || actions.length === 0) return;
|
|
1890
|
+
for (const action of actions) {
|
|
1891
|
+
try {
|
|
1892
|
+
action.execute(event);
|
|
1893
|
+
} catch (error) {
|
|
1894
|
+
console.warn(
|
|
1895
|
+
`[ActionDispatcher] Error executing action ${action.actionId} for gesture ${event.name}:`,
|
|
1896
|
+
error
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
this.eventBus.emit("action:dispatched" /* ActionDispatched */, event);
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
// src/actions/NavigationAction.ts
|
|
1905
|
+
var NavigationAction = class {
|
|
1906
|
+
constructor(actionId, callback) {
|
|
1907
|
+
this.actionId = actionId;
|
|
1908
|
+
this.callback = callback;
|
|
1909
|
+
}
|
|
1910
|
+
execute(event) {
|
|
1911
|
+
this.callback(event);
|
|
1912
|
+
}
|
|
1913
|
+
};
|
|
1914
|
+
|
|
1915
|
+
// src/actions/UITransformAction.ts
|
|
1916
|
+
var UITransformAction = class {
|
|
1917
|
+
constructor(actionId, transform) {
|
|
1918
|
+
this.actionId = actionId;
|
|
1919
|
+
this.transform = transform;
|
|
1920
|
+
}
|
|
1921
|
+
execute(event) {
|
|
1922
|
+
this.transform(event);
|
|
1923
|
+
}
|
|
1924
|
+
};
|
|
1925
|
+
|
|
1926
|
+
// src/actions/SystemAction.ts
|
|
1927
|
+
var SystemAction = class {
|
|
1928
|
+
constructor(actionId, callback) {
|
|
1929
|
+
this.actionId = actionId;
|
|
1930
|
+
this.callback = callback;
|
|
1931
|
+
}
|
|
1932
|
+
execute(event) {
|
|
1933
|
+
this.callback(event);
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
|
|
1937
|
+
// src/actions/CustomAction.ts
|
|
1938
|
+
var CustomAction = class {
|
|
1939
|
+
constructor(actionId, callback) {
|
|
1940
|
+
this.actionId = actionId;
|
|
1941
|
+
this.callback = callback;
|
|
1942
|
+
}
|
|
1943
|
+
execute(event) {
|
|
1944
|
+
this.callback(event);
|
|
1945
|
+
}
|
|
1946
|
+
};
|
|
1947
|
+
|
|
1948
|
+
// src/feedback/HapticFeedback.ts
|
|
1949
|
+
var Haptics;
|
|
1950
|
+
var Vibration;
|
|
1951
|
+
function loadModules() {
|
|
1952
|
+
try {
|
|
1953
|
+
Haptics = __require("expo-haptics");
|
|
1954
|
+
} catch {
|
|
1955
|
+
}
|
|
1956
|
+
try {
|
|
1957
|
+
const rn = __require("react-native");
|
|
1958
|
+
Vibration = rn.Vibration;
|
|
1959
|
+
} catch {
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
var HapticFeedback = class {
|
|
1963
|
+
constructor(enabled = true) {
|
|
1964
|
+
this._isSupported = false;
|
|
1965
|
+
this.useVibrationFallback = false;
|
|
1966
|
+
this.enabled = enabled;
|
|
1967
|
+
loadModules();
|
|
1968
|
+
if (Haptics) {
|
|
1969
|
+
this._isSupported = true;
|
|
1970
|
+
} else if (Vibration) {
|
|
1971
|
+
this._isSupported = true;
|
|
1972
|
+
this.useVibrationFallback = true;
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
get isSupported() {
|
|
1976
|
+
return this._isSupported && this.enabled;
|
|
1977
|
+
}
|
|
1978
|
+
trigger(event) {
|
|
1979
|
+
if (!this.isSupported) return;
|
|
1980
|
+
if (event.state !== "began" /* Began */ && event.state !== "ended" /* Ended */) {
|
|
1981
|
+
return;
|
|
1982
|
+
}
|
|
1983
|
+
if (this.useVibrationFallback) {
|
|
1984
|
+
Vibration?.vibrate(50);
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
const gestureName = event.name;
|
|
1988
|
+
if (gestureName.startsWith("sequence:") || gestureName === "shake") {
|
|
1989
|
+
Haptics?.notificationAsync?.(Haptics.NotificationFeedbackType?.Success);
|
|
1990
|
+
} else if (gestureName === "tap" || gestureName === "double-tap") {
|
|
1991
|
+
Haptics?.impactAsync?.(Haptics.ImpactFeedbackStyle?.Light);
|
|
1992
|
+
} else if (gestureName.startsWith("edge-swipe") || gestureName.startsWith("corner")) {
|
|
1993
|
+
Haptics?.impactAsync?.(Haptics.ImpactFeedbackStyle?.Medium);
|
|
1994
|
+
} else {
|
|
1995
|
+
Haptics?.selectionAsync?.();
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
setEnabled(enabled) {
|
|
1999
|
+
this.enabled = enabled;
|
|
2000
|
+
}
|
|
2001
|
+
};
|
|
2002
|
+
|
|
2003
|
+
// src/feedback/VisualFeedback.ts
|
|
2004
|
+
var VisualFeedback = class {
|
|
2005
|
+
constructor(callback) {
|
|
2006
|
+
this._isSupported = true;
|
|
2007
|
+
this.callback = callback ?? null;
|
|
2008
|
+
}
|
|
2009
|
+
get isSupported() {
|
|
2010
|
+
return this._isSupported;
|
|
2011
|
+
}
|
|
2012
|
+
trigger(event) {
|
|
2013
|
+
if (!this._isSupported || !this.callback) return;
|
|
2014
|
+
this.callback(event);
|
|
2015
|
+
}
|
|
2016
|
+
/**
|
|
2017
|
+
* Update the visual feedback callback at runtime.
|
|
2018
|
+
*/
|
|
2019
|
+
setCallback(callback) {
|
|
2020
|
+
this.callback = callback;
|
|
2021
|
+
}
|
|
2022
|
+
};
|
|
2023
|
+
|
|
2024
|
+
// src/feedback/AccessibilityFeedback.ts
|
|
2025
|
+
var AccessibilityInfo;
|
|
2026
|
+
function loadAccessibility() {
|
|
2027
|
+
try {
|
|
2028
|
+
const rn = __require("react-native");
|
|
2029
|
+
AccessibilityInfo = rn.AccessibilityInfo;
|
|
2030
|
+
} catch {
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
var AccessibilityFeedback = class {
|
|
2034
|
+
constructor() {
|
|
2035
|
+
this._isSupported = false;
|
|
2036
|
+
this.announcementBuilder = null;
|
|
2037
|
+
loadAccessibility();
|
|
2038
|
+
this._isSupported = !!AccessibilityInfo?.announceForAccessibility;
|
|
2039
|
+
}
|
|
2040
|
+
get isSupported() {
|
|
2041
|
+
return this._isSupported;
|
|
2042
|
+
}
|
|
2043
|
+
trigger(event) {
|
|
2044
|
+
if (!this._isSupported) return;
|
|
2045
|
+
if (event.state !== "ended" /* Ended */) return;
|
|
2046
|
+
const announcement = this.announcementBuilder ? this.announcementBuilder(event) : this.defaultAnnouncement(event);
|
|
2047
|
+
AccessibilityInfo.announceForAccessibility(announcement);
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Set a custom function to build announcement strings.
|
|
2051
|
+
*/
|
|
2052
|
+
setAnnouncementBuilder(builder) {
|
|
2053
|
+
this.announcementBuilder = builder;
|
|
2054
|
+
}
|
|
2055
|
+
/**
|
|
2056
|
+
* Generate a default human-readable announcement based on gesture name.
|
|
2057
|
+
*/
|
|
2058
|
+
defaultAnnouncement(event) {
|
|
2059
|
+
const name = event.name.replace(/-/g, " ").replace(/:/g, " ").replace(/>/g, " then ");
|
|
2060
|
+
return `Gesture detected: ${name}`;
|
|
2061
|
+
}
|
|
2062
|
+
};
|
|
2063
|
+
|
|
2064
|
+
// src/GestureEngine.ts
|
|
2065
|
+
var GestureEngine = class {
|
|
2066
|
+
constructor(config = {}) {
|
|
2067
|
+
// ─── Layer 3: Recognition ──────────────────────────────────────────
|
|
2068
|
+
this.recognizers = [];
|
|
2069
|
+
// ─── Layer 6: Feedback ─────────────────────────────────────────────
|
|
2070
|
+
this.feedbackProviders = [];
|
|
2071
|
+
// ─── State ─────────────────────────────────────────────────────────
|
|
2072
|
+
this._isRunning = false;
|
|
2073
|
+
this.inputUnsubscribe = null;
|
|
2074
|
+
this.feedbackUnsubscribe = null;
|
|
2075
|
+
this.config = {
|
|
2076
|
+
sensorInterval: Math.max(16, config.sensorInterval ?? 100),
|
|
2077
|
+
hapticEnabled: config.hapticEnabled ?? true,
|
|
2078
|
+
debug: config.debug ?? false,
|
|
2079
|
+
screenWidth: config.screenWidth ?? 400,
|
|
2080
|
+
screenHeight: config.screenHeight ?? 800
|
|
2081
|
+
};
|
|
2082
|
+
this.eventBus = new EventBus();
|
|
2083
|
+
this.touchInput = new TouchInputProvider(this.eventBus);
|
|
2084
|
+
this.sensorInput = new SensorInputProvider(
|
|
2085
|
+
this.eventBus,
|
|
2086
|
+
this.config.sensorInterval
|
|
2087
|
+
);
|
|
2088
|
+
this.noiseFilter = new NoiseFilter(0.8);
|
|
2089
|
+
this.sensorNoiseFilter = new NoiseFilter(0.2);
|
|
2090
|
+
this.velocityCalc = new VelocityCalculator();
|
|
2091
|
+
this.angleDetector = new AngleDetector();
|
|
2092
|
+
this.normalizer = new ThresholdNormalizer(0, 10);
|
|
2093
|
+
this.streamBuffer = new StreamBuffer(400, 64);
|
|
2094
|
+
this.conflictResolver = new ConflictResolver(this.eventBus);
|
|
2095
|
+
this.actionDispatcher = new ActionDispatcher(this.eventBus);
|
|
2096
|
+
}
|
|
2097
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
2098
|
+
// Public API
|
|
2099
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
2100
|
+
get isRunning() {
|
|
2101
|
+
return this._isRunning;
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* Start the gesture engine. Activates all providers and wires the pipeline.
|
|
2105
|
+
*/
|
|
2106
|
+
start() {
|
|
2107
|
+
if (this._isRunning) return;
|
|
2108
|
+
this._isRunning = true;
|
|
2109
|
+
this.inputUnsubscribe = this.eventBus.on(
|
|
2110
|
+
"input:raw" /* InputRaw */,
|
|
2111
|
+
(event) => this.processInput(event)
|
|
2112
|
+
);
|
|
2113
|
+
this.feedbackUnsubscribe = this.eventBus.on(
|
|
2114
|
+
"action:dispatched" /* ActionDispatched */,
|
|
2115
|
+
(event) => {
|
|
2116
|
+
for (const provider of this.feedbackProviders) {
|
|
2117
|
+
if (provider.isSupported) {
|
|
2118
|
+
provider.trigger(event);
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
);
|
|
2123
|
+
this.touchInput.start();
|
|
2124
|
+
this.sensorInput.start();
|
|
2125
|
+
this.conflictResolver.start();
|
|
2126
|
+
this.actionDispatcher.start();
|
|
2127
|
+
if (this.config.debug) {
|
|
2128
|
+
console.log("[GestureEngine] Started with config:", this.config);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
/**
|
|
2132
|
+
* Stop the gesture engine. Cleans up all subscriptions and providers.
|
|
2133
|
+
*/
|
|
2134
|
+
stop() {
|
|
2135
|
+
if (!this._isRunning) return;
|
|
2136
|
+
this._isRunning = false;
|
|
2137
|
+
this.touchInput.stop();
|
|
2138
|
+
this.sensorInput.stop();
|
|
2139
|
+
this.conflictResolver.stop();
|
|
2140
|
+
this.actionDispatcher.stop();
|
|
2141
|
+
this.inputUnsubscribe?.();
|
|
2142
|
+
this.feedbackUnsubscribe?.();
|
|
2143
|
+
this.inputUnsubscribe = null;
|
|
2144
|
+
this.feedbackUnsubscribe = null;
|
|
2145
|
+
this.noiseFilter.reset();
|
|
2146
|
+
this.sensorNoiseFilter.reset();
|
|
2147
|
+
this.velocityCalc.reset();
|
|
2148
|
+
this.streamBuffer.clear();
|
|
2149
|
+
for (const recognizer of this.recognizers) {
|
|
2150
|
+
recognizer.reset();
|
|
2151
|
+
}
|
|
2152
|
+
if (this.config.debug) {
|
|
2153
|
+
console.log("[GestureEngine] Stopped");
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* Register a gesture recognizer with the engine.
|
|
2158
|
+
*/
|
|
2159
|
+
registerRecognizer(recognizer) {
|
|
2160
|
+
this.recognizers.push(recognizer);
|
|
2161
|
+
if (this.config.debug) {
|
|
2162
|
+
console.log(`[GestureEngine] Registered recognizer: ${recognizer.name}`);
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* Unregister a recognizer by its ID.
|
|
2167
|
+
*/
|
|
2168
|
+
unregisterRecognizer(recognizerId) {
|
|
2169
|
+
const index = this.recognizers.findIndex((r) => r.id === recognizerId);
|
|
2170
|
+
if (index !== -1) {
|
|
2171
|
+
const [removed] = this.recognizers.splice(index, 1);
|
|
2172
|
+
removed.dispose();
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
/**
|
|
2176
|
+
* Register an action for a gesture name.
|
|
2177
|
+
*/
|
|
2178
|
+
registerAction(gestureName, action) {
|
|
2179
|
+
this.actionDispatcher.registerAction(gestureName, action);
|
|
2180
|
+
}
|
|
2181
|
+
/**
|
|
2182
|
+
* Register a feedback provider.
|
|
2183
|
+
*/
|
|
2184
|
+
registerFeedback(provider) {
|
|
2185
|
+
this.feedbackProviders.push(provider);
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Get all registered recognizers.
|
|
2189
|
+
*/
|
|
2190
|
+
getRecognizers() {
|
|
2191
|
+
return [...this.recognizers];
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* Dispose the engine and clean up all resources.
|
|
2195
|
+
*/
|
|
2196
|
+
dispose() {
|
|
2197
|
+
this.stop();
|
|
2198
|
+
for (const recognizer of this.recognizers) {
|
|
2199
|
+
recognizer.dispose();
|
|
2200
|
+
}
|
|
2201
|
+
this.recognizers = [];
|
|
2202
|
+
this.feedbackProviders = [];
|
|
2203
|
+
this.actionDispatcher.clearActions();
|
|
2204
|
+
this.eventBus.clear();
|
|
2205
|
+
}
|
|
2206
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
2207
|
+
// Pipeline: Input → Processing → Recognition
|
|
2208
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
2209
|
+
/**
|
|
2210
|
+
* Process a raw input event through Layer 2 (processing) and feed
|
|
2211
|
+
* the resulting ProcessedSample into Layer 3 (recognition).
|
|
2212
|
+
*/
|
|
2213
|
+
processInput(event) {
|
|
2214
|
+
let filtered;
|
|
2215
|
+
let velocityResult;
|
|
2216
|
+
if (event.inputType === "touch" /* Touch */) {
|
|
2217
|
+
const touch = event.data;
|
|
2218
|
+
filtered = this.noiseFilter.lowPass(touch.x, touch.y, 0);
|
|
2219
|
+
velocityResult = this.velocityCalc.calculate(
|
|
2220
|
+
touch.x,
|
|
2221
|
+
touch.y,
|
|
2222
|
+
event.timestamp
|
|
2223
|
+
);
|
|
2224
|
+
} else if (event.inputType === "sensor" /* Sensor */) {
|
|
2225
|
+
const sensor = event.data;
|
|
2226
|
+
filtered = this.sensorNoiseFilter.highPass(sensor.x, sensor.y, sensor.z);
|
|
2227
|
+
const magnitude = Math.sqrt(
|
|
2228
|
+
filtered.x ** 2 + filtered.y ** 2 + filtered.z ** 2
|
|
2229
|
+
);
|
|
2230
|
+
velocityResult = { velocityX: filtered.x, velocityY: filtered.y, velocity: magnitude };
|
|
2231
|
+
} else {
|
|
2232
|
+
filtered = { x: 0, y: 0, z: 0 };
|
|
2233
|
+
velocityResult = { velocityX: 0, velocityY: 0, velocity: 0 };
|
|
2234
|
+
}
|
|
2235
|
+
const angle = this.angleDetector.calculate(
|
|
2236
|
+
velocityResult.velocityX,
|
|
2237
|
+
velocityResult.velocityY
|
|
2238
|
+
);
|
|
2239
|
+
const normalizedMagnitude = this.normalizer.normalize(velocityResult.velocity);
|
|
2240
|
+
const sample = {
|
|
2241
|
+
inputEvent: event,
|
|
2242
|
+
velocity: velocityResult.velocity,
|
|
2243
|
+
velocityX: velocityResult.velocityX,
|
|
2244
|
+
velocityY: velocityResult.velocityY,
|
|
2245
|
+
angleRadians: angle.angleRadians,
|
|
2246
|
+
angleDegrees: angle.angleDegrees,
|
|
2247
|
+
direction: angle.direction,
|
|
2248
|
+
normalizedMagnitude,
|
|
2249
|
+
filtered,
|
|
2250
|
+
timestamp: event.timestamp
|
|
2251
|
+
};
|
|
2252
|
+
this.streamBuffer.push(sample);
|
|
2253
|
+
this.eventBus.emit("processing:sample" /* ProcessingSample */, sample);
|
|
2254
|
+
for (const recognizer of this.recognizers) {
|
|
2255
|
+
if (recognizer.enabled) {
|
|
2256
|
+
recognizer.onProcessedSample(sample);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
};
|
|
2261
|
+
|
|
2262
|
+
// src/hooks/useGestureEngine.ts
|
|
2263
|
+
function useGestureEngine(config = {}) {
|
|
2264
|
+
const engineRef = useRef(null);
|
|
2265
|
+
const [isReady, setIsReady] = useState(false);
|
|
2266
|
+
useEffect(() => {
|
|
2267
|
+
const engine = new GestureEngine({
|
|
2268
|
+
sensorInterval: config.sensorInterval,
|
|
2269
|
+
hapticEnabled: config.hapticEnabled,
|
|
2270
|
+
debug: config.debug,
|
|
2271
|
+
screenWidth: config.screenWidth,
|
|
2272
|
+
screenHeight: config.screenHeight
|
|
2273
|
+
});
|
|
2274
|
+
if (config.recognizers) {
|
|
2275
|
+
for (const recognizer of config.recognizers) {
|
|
2276
|
+
engine.registerRecognizer(recognizer);
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
if (config.actions) {
|
|
2280
|
+
for (const [gestureName, actions] of Object.entries(config.actions)) {
|
|
2281
|
+
for (const action of actions) {
|
|
2282
|
+
engine.registerAction(gestureName, action);
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
if (config.feedback) {
|
|
2287
|
+
for (const provider of config.feedback) {
|
|
2288
|
+
engine.registerFeedback(provider);
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
engine.start();
|
|
2292
|
+
engineRef.current = engine;
|
|
2293
|
+
setIsReady(true);
|
|
2294
|
+
return () => {
|
|
2295
|
+
engine.dispose();
|
|
2296
|
+
engineRef.current = null;
|
|
2297
|
+
setIsReady(false);
|
|
2298
|
+
};
|
|
2299
|
+
}, []);
|
|
2300
|
+
return {
|
|
2301
|
+
engine: engineRef.current,
|
|
2302
|
+
isReady
|
|
2303
|
+
};
|
|
2304
|
+
}
|
|
2305
|
+
function useShakeGesture(config) {
|
|
2306
|
+
const engineRef = useRef(null);
|
|
2307
|
+
const callbackRef = useRef(config.onShake);
|
|
2308
|
+
callbackRef.current = config.onShake;
|
|
2309
|
+
useEffect(() => {
|
|
2310
|
+
const engine = new GestureEngine({
|
|
2311
|
+
sensorInterval: config.sensorInterval ?? 100,
|
|
2312
|
+
hapticEnabled: config.hapticEnabled ?? true
|
|
2313
|
+
});
|
|
2314
|
+
const shakeRecognizer = new ShakeRecognizer(engine.eventBus, {
|
|
2315
|
+
threshold: config.threshold,
|
|
2316
|
+
cooldownMs: config.cooldownMs
|
|
2317
|
+
});
|
|
2318
|
+
engine.registerRecognizer(shakeRecognizer);
|
|
2319
|
+
const action = new CustomAction("shake-callback", () => {
|
|
2320
|
+
callbackRef.current();
|
|
2321
|
+
});
|
|
2322
|
+
engine.registerAction("shake", action);
|
|
2323
|
+
if (config.hapticEnabled !== false) {
|
|
2324
|
+
engine.registerFeedback(new HapticFeedback(true));
|
|
2325
|
+
}
|
|
2326
|
+
engine.start();
|
|
2327
|
+
engineRef.current = engine;
|
|
2328
|
+
return () => {
|
|
2329
|
+
engine.dispose();
|
|
2330
|
+
engineRef.current = null;
|
|
2331
|
+
};
|
|
2332
|
+
}, [config.threshold, config.cooldownMs, config.sensorInterval]);
|
|
2333
|
+
}
|
|
2334
|
+
function useEdgeSwipe(config) {
|
|
2335
|
+
const engineRef = useRef(null);
|
|
2336
|
+
const callbackRef = useRef(config.onSwipe);
|
|
2337
|
+
callbackRef.current = config.onSwipe;
|
|
2338
|
+
useEffect(() => {
|
|
2339
|
+
const engine = new GestureEngine({
|
|
2340
|
+
hapticEnabled: config.hapticEnabled ?? true,
|
|
2341
|
+
screenWidth: config.screenWidth,
|
|
2342
|
+
screenHeight: config.screenHeight
|
|
2343
|
+
});
|
|
2344
|
+
const gestureName = `edge-swipe-${config.edge}`;
|
|
2345
|
+
const recognizer = new EdgeSwipeRecognizer(engine.eventBus, {
|
|
2346
|
+
edge: config.edge,
|
|
2347
|
+
minDistance: config.minDistance,
|
|
2348
|
+
edgeZoneWidth: config.edgeZoneWidth,
|
|
2349
|
+
minVelocity: config.minVelocity,
|
|
2350
|
+
screenWidth: config.screenWidth,
|
|
2351
|
+
screenHeight: config.screenHeight
|
|
2352
|
+
});
|
|
2353
|
+
engine.registerRecognizer(recognizer);
|
|
2354
|
+
const action = new CustomAction(`${gestureName}-callback`, (event) => {
|
|
2355
|
+
callbackRef.current(event);
|
|
2356
|
+
});
|
|
2357
|
+
engine.registerAction(gestureName, action);
|
|
2358
|
+
if (config.hapticEnabled !== false) {
|
|
2359
|
+
engine.registerFeedback(new HapticFeedback(true));
|
|
2360
|
+
}
|
|
2361
|
+
engine.start();
|
|
2362
|
+
engineRef.current = engine;
|
|
2363
|
+
return () => {
|
|
2364
|
+
engine.dispose();
|
|
2365
|
+
engineRef.current = null;
|
|
2366
|
+
};
|
|
2367
|
+
}, [config.edge, config.minDistance, config.edgeZoneWidth, config.minVelocity]);
|
|
2368
|
+
}
|
|
2369
|
+
function useGestureSequence(config) {
|
|
2370
|
+
const engineRef = useRef(null);
|
|
2371
|
+
const callbackRef = useRef(config.onComplete);
|
|
2372
|
+
callbackRef.current = config.onComplete;
|
|
2373
|
+
useEffect(() => {
|
|
2374
|
+
const engine = new GestureEngine({
|
|
2375
|
+
hapticEnabled: config.hapticEnabled ?? true
|
|
2376
|
+
});
|
|
2377
|
+
const sequenceName = `sequence:${config.sequence.join(">")}`;
|
|
2378
|
+
const recognizer = new SequenceRecognizer(engine.eventBus, {
|
|
2379
|
+
sequence: config.sequence,
|
|
2380
|
+
timeoutMs: config.timeoutMs
|
|
2381
|
+
});
|
|
2382
|
+
engine.registerRecognizer(recognizer);
|
|
2383
|
+
const action = new CustomAction(`${sequenceName}-callback`, () => {
|
|
2384
|
+
callbackRef.current();
|
|
2385
|
+
});
|
|
2386
|
+
engine.registerAction(sequenceName, action);
|
|
2387
|
+
if (config.hapticEnabled !== false) {
|
|
2388
|
+
engine.registerFeedback(new HapticFeedback(true));
|
|
2389
|
+
}
|
|
2390
|
+
engine.start();
|
|
2391
|
+
engineRef.current = engine;
|
|
2392
|
+
return () => {
|
|
2393
|
+
engine.dispose();
|
|
2394
|
+
engineRef.current = null;
|
|
2395
|
+
};
|
|
2396
|
+
}, [config.sequence.join(","), config.timeoutMs]);
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
export { AccessibilityFeedback, ActionDispatcher, AngleDetector, BaseRecognizer, CameraInputProvider, CardinalDirection, ConflictResolver, CornerRecognizer, CustomAction, DoubleTapRecognizer, EdgeSwipeRecognizer, EventBus, EventChannel, GestureEngine, GesturePriorityQueue, HapticFeedback, HardwareInputProvider, InputType, LockManager, NavigationAction, NoiseFilter, PanRecognizer, PinchRecognizer, RecognizerState, RotationRecognizer, SensorInputProvider, SensorType, SequenceRecognizer, ShakeRecognizer, StreamBuffer, SymbolRecognizer, SystemAction, TapRecognizer, ThresholdNormalizer, TiltRecognizer, TouchInputProvider, TouchType, UITransformAction, VelocityCalculator, VisualFeedback, WristFlickRecognizer, generateId, useEdgeSwipe, useGestureEngine, useGestureSequence, useShakeGesture };
|
|
2400
|
+
//# sourceMappingURL=index.mjs.map
|
|
2401
|
+
//# sourceMappingURL=index.mjs.map
|