@map-gesture-controls/core 0.1.7 → 0.1.9
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 +29 -13
- package/dist/GestureController.d.ts +2 -0
- package/dist/GestureController.js +16 -2
- package/dist/GestureStateMachine.d.ts +17 -7
- package/dist/GestureStateMachine.js +151 -32
- package/dist/GestureStateMachine.test.js +124 -23
- package/dist/WebcamOverlay.js +1 -0
- package/dist/constants.d.ts +3 -0
- package/dist/constants.js +7 -0
- package/dist/gestureClassifier.d.ts +18 -3
- package/dist/gestureClassifier.js +73 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +197 -144
- package/dist/types.d.ts +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -10,13 +10,13 @@
|
|
|
10
10
|
> Building with OpenLayers? Use [`@map-gesture-controls/ol`](https://www.npmjs.com/package/@map-gesture-controls/ol) instead. It wraps this package and adds map integration out of the box.
|
|
11
11
|
|
|
12
12
|
<p align="center">
|
|
13
|
-
<img src="https://raw.githubusercontent.com/sanderdesnaijer/map-gesture-controls/main/docs/public/openlayers-gesture-control-demo.gif" alt="Screen recording of the map gesture demo: an OpenLayers map with a small webcam preview; the user pans with
|
|
13
|
+
<img src="https://raw.githubusercontent.com/sanderdesnaijer/map-gesture-controls/main/docs/public/openlayers-gesture-control-demo.gif" alt="Screen recording of the map gesture demo: an OpenLayers map with a small webcam preview; the user pans with the left fist, zooms with the right fist, and rotates with both fists, all in the browser via MediaPipe." width="720" />
|
|
14
14
|
</p>
|
|
15
15
|
|
|
16
16
|
## What it does
|
|
17
17
|
|
|
18
18
|
- Detects hands and classifies gestures at 30+ fps using MediaPipe Hand Landmarker
|
|
19
|
-
- Recognizes **fist** (pan
|
|
19
|
+
- Recognizes **fist** and **pinch** as interchangeable triggers (left = pan, right = zoom, both = rotate)
|
|
20
20
|
- Manages gesture transitions with dwell timers and grace periods to avoid flickering
|
|
21
21
|
- Provides a configurable webcam overlay with corner/full/hidden display modes
|
|
22
22
|
- Ships fully typed TypeScript declarations
|
|
@@ -30,18 +30,26 @@ npm install @map-gesture-controls/core
|
|
|
30
30
|
## Quick start
|
|
31
31
|
|
|
32
32
|
```ts
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
GestureController,
|
|
35
|
+
GestureStateMachine,
|
|
36
|
+
DEFAULT_TUNING_CONFIG,
|
|
37
|
+
} from '@map-gesture-controls/core';
|
|
34
38
|
import '@map-gesture-controls/core/style.css';
|
|
35
39
|
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
const tuning = DEFAULT_TUNING_CONFIG;
|
|
41
|
+
const stateMachine = new GestureStateMachine(tuning);
|
|
42
|
+
|
|
43
|
+
const controller = new GestureController(tuning, (frame) => {
|
|
44
|
+
const output = stateMachine.update(frame);
|
|
45
|
+
// output.mode: 'idle' | 'panning' | 'zooming' | 'rotating'
|
|
46
|
+
// output.panDelta, output.zoomDelta, output.rotateDelta
|
|
47
|
+
console.log(output);
|
|
41
48
|
});
|
|
42
49
|
|
|
43
50
|
// Must be called from a user interaction (button click) for webcam permission
|
|
44
|
-
await controller.
|
|
51
|
+
await controller.init();
|
|
52
|
+
controller.start();
|
|
45
53
|
```
|
|
46
54
|
|
|
47
55
|
## Exports
|
|
@@ -51,7 +59,8 @@ await controller.start();
|
|
|
51
59
|
| `GestureController` | Class | Opens the webcam, runs MediaPipe detection, and emits gesture frames |
|
|
52
60
|
| `GestureStateMachine` | Class | Manages gesture state transitions with dwell and grace timers |
|
|
53
61
|
| `WebcamOverlay` | Class | Renders a configurable camera preview overlay |
|
|
54
|
-
| `classifyGesture` | Function |
|
|
62
|
+
| `classifyGesture` | Function | Stateless classifier: returns `fist`, `pinch`, `openPalm`, or `none` for a set of landmarks |
|
|
63
|
+
| `createHandClassifier` | Function | Returns a stateful per-hand classifier with pinch hysteresis (use this instead of `classifyGesture` in custom pipelines) |
|
|
55
64
|
| `getHandSize` | Function | Computes the bounding size of a hand from its landmarks |
|
|
56
65
|
| `getTwoHandDistance` | Function | Measures the distance between two detected hands |
|
|
57
66
|
| `DEFAULT_WEBCAM_CONFIG` | Constant | Default webcam overlay settings |
|
|
@@ -61,12 +70,19 @@ Full TypeScript types are exported for `GestureMode`, `GestureFrame`, `DetectedH
|
|
|
61
70
|
|
|
62
71
|
## Gesture recognition
|
|
63
72
|
|
|
64
|
-
|
|
73
|
+
Both **fist** and **pinch** trigger the same map actions — users can use whichever is more comfortable.
|
|
74
|
+
|
|
75
|
+
| Gesture | Detection rule | Map action |
|
|
65
76
|
| --- | --- | --- |
|
|
66
|
-
| **
|
|
67
|
-
| **
|
|
77
|
+
| **Left fist** | Left hand, 3+ fingers curled | Pan / drag |
|
|
78
|
+
| **Left pinch** | Left hand, thumb and index tip within 25% of hand size (exits at 35%) | Pan / drag |
|
|
79
|
+
| **Right fist** | Right hand, 3+ fingers curled | Zoom (move up = in, down = out) |
|
|
80
|
+
| **Right pinch** | Right hand, thumb and index tip within 25% of hand size (exits at 35%) | Zoom (move up = in, down = out) |
|
|
81
|
+
| **Both hands active** | Both hands fist or pinch (mixed is fine) | Rotate map |
|
|
68
82
|
| **Idle** | Anything else | No action |
|
|
69
83
|
|
|
84
|
+
Pinch detection uses hysteresis: the gesture is entered at 25% of hand size and held until fingers open beyond 35%. This prevents flickering when fingers hover near the threshold during a held pinch.
|
|
85
|
+
|
|
70
86
|
Gestures are confirmed after a configurable dwell period (default 80 ms) and held through a grace period (default 150 ms) to prevent flickering when tracking briefly drops.
|
|
71
87
|
|
|
72
88
|
## Use cases
|
|
@@ -15,6 +15,8 @@ export declare class GestureController {
|
|
|
15
15
|
private onFrame;
|
|
16
16
|
private tuning;
|
|
17
17
|
private lastVideoTime;
|
|
18
|
+
private leftClassifier;
|
|
19
|
+
private rightClassifier;
|
|
18
20
|
constructor(tuning: TuningConfig, onFrame: FrameCallback);
|
|
19
21
|
/**
|
|
20
22
|
* Initialise MediaPipe and request webcam access.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FilesetResolver } from '@mediapipe/tasks-vision';
|
|
2
|
-
import {
|
|
2
|
+
import { createHandClassifier } from './gestureClassifier.js';
|
|
3
3
|
import { MEDIAPIPE_WASM_URL } from './constants.js';
|
|
4
4
|
/**
|
|
5
5
|
* GestureController
|
|
@@ -57,6 +57,19 @@ export class GestureController {
|
|
|
57
57
|
writable: true,
|
|
58
58
|
value: -1
|
|
59
59
|
});
|
|
60
|
+
// One stateful classifier per hand label; persists pinch hysteresis across frames.
|
|
61
|
+
Object.defineProperty(this, "leftClassifier", {
|
|
62
|
+
enumerable: true,
|
|
63
|
+
configurable: true,
|
|
64
|
+
writable: true,
|
|
65
|
+
value: createHandClassifier()
|
|
66
|
+
});
|
|
67
|
+
Object.defineProperty(this, "rightClassifier", {
|
|
68
|
+
enumerable: true,
|
|
69
|
+
configurable: true,
|
|
70
|
+
writable: true,
|
|
71
|
+
value: createHandClassifier()
|
|
72
|
+
});
|
|
60
73
|
this.tuning = tuning;
|
|
61
74
|
this.onFrame = onFrame;
|
|
62
75
|
}
|
|
@@ -146,7 +159,8 @@ export class GestureController {
|
|
|
146
159
|
const rawLabel = handednessArr?.[0]?.categoryName;
|
|
147
160
|
const label = rawLabel === 'Left' ? 'Left' : 'Right';
|
|
148
161
|
const score = handednessArr?.[0]?.score ?? 0;
|
|
149
|
-
const
|
|
162
|
+
const classify = label === 'Left' ? this.leftClassifier : this.rightClassifier;
|
|
163
|
+
const gesture = classify(landmarks);
|
|
150
164
|
return { handedness: label, score, landmarks, gesture };
|
|
151
165
|
});
|
|
152
166
|
const leftHand = hands.find((h) => h.handedness === 'Left') ?? null;
|
|
@@ -3,19 +3,24 @@ export interface StateMachineOutput {
|
|
|
3
3
|
mode: GestureMode;
|
|
4
4
|
panDelta: SmoothedPoint | null;
|
|
5
5
|
zoomDelta: number | null;
|
|
6
|
+
rotateDelta: number | null;
|
|
6
7
|
}
|
|
7
8
|
/**
|
|
8
|
-
* GestureStateMachine:
|
|
9
|
+
* GestureStateMachine: 4-state FSM
|
|
9
10
|
*
|
|
10
11
|
* Priority rules (evaluated every frame):
|
|
11
|
-
* both hands
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* both hands fist → desired = 'rotating'
|
|
13
|
+
* angle of the wrist-to-wrist line; delta rotates the map
|
|
14
|
+
* right hand fist (left absent or not fist) → desired = 'zooming'
|
|
15
|
+
* vertical motion of right wrist controls zoom (up = in, down = out)
|
|
16
|
+
* left hand fist (right absent or not fist) → desired = 'panning'
|
|
17
|
+
* horizontal and vertical motion of left wrist pans the map
|
|
18
|
+
* otherwise → desired = 'idle'
|
|
14
19
|
*
|
|
15
20
|
* Transitions:
|
|
16
|
-
* idle →
|
|
17
|
-
*
|
|
18
|
-
*
|
|
21
|
+
* idle → any active : desired stable for actionDwellMs
|
|
22
|
+
* active → idle : desired changes, grace period releaseGraceMs,
|
|
23
|
+
* then idle (next frame starts new dwell if needed)
|
|
19
24
|
*/
|
|
20
25
|
export declare class GestureStateMachine {
|
|
21
26
|
private tuning;
|
|
@@ -26,6 +31,11 @@ export declare class GestureStateMachine {
|
|
|
26
31
|
private prevPanPos;
|
|
27
32
|
private zoomSmoother;
|
|
28
33
|
private prevZoomDist;
|
|
34
|
+
private rotateSmoother;
|
|
35
|
+
private prevRotateAngle;
|
|
36
|
+
private leftActiveFrames;
|
|
37
|
+
private rightActiveFrames;
|
|
38
|
+
private static readonly ESCALATION_FRAMES;
|
|
29
39
|
constructor(tuning: TuningConfig);
|
|
30
40
|
getMode(): GestureMode;
|
|
31
41
|
update(frame: GestureFrame): StateMachineOutput;
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { getTwoHandDistance } from './gestureClassifier.js';
|
|
2
1
|
/**
|
|
3
2
|
* Exponential Moving Average smoother for 2D points.
|
|
4
3
|
*/
|
|
@@ -65,17 +64,21 @@ class EMAScalar {
|
|
|
65
64
|
}
|
|
66
65
|
}
|
|
67
66
|
/**
|
|
68
|
-
* GestureStateMachine:
|
|
67
|
+
* GestureStateMachine: 4-state FSM
|
|
69
68
|
*
|
|
70
69
|
* Priority rules (evaluated every frame):
|
|
71
|
-
* both hands
|
|
72
|
-
*
|
|
73
|
-
*
|
|
70
|
+
* both hands fist → desired = 'rotating'
|
|
71
|
+
* angle of the wrist-to-wrist line; delta rotates the map
|
|
72
|
+
* right hand fist (left absent or not fist) → desired = 'zooming'
|
|
73
|
+
* vertical motion of right wrist controls zoom (up = in, down = out)
|
|
74
|
+
* left hand fist (right absent or not fist) → desired = 'panning'
|
|
75
|
+
* horizontal and vertical motion of left wrist pans the map
|
|
76
|
+
* otherwise → desired = 'idle'
|
|
74
77
|
*
|
|
75
78
|
* Transitions:
|
|
76
|
-
* idle →
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
+
* idle → any active : desired stable for actionDwellMs
|
|
80
|
+
* active → idle : desired changes, grace period releaseGraceMs,
|
|
81
|
+
* then idle (next frame starts new dwell if needed)
|
|
79
82
|
*/
|
|
80
83
|
export class GestureStateMachine {
|
|
81
84
|
constructor(tuning) {
|
|
@@ -127,8 +130,37 @@ export class GestureStateMachine {
|
|
|
127
130
|
writable: true,
|
|
128
131
|
value: null
|
|
129
132
|
});
|
|
133
|
+
Object.defineProperty(this, "rotateSmoother", {
|
|
134
|
+
enumerable: true,
|
|
135
|
+
configurable: true,
|
|
136
|
+
writable: true,
|
|
137
|
+
value: void 0
|
|
138
|
+
});
|
|
139
|
+
Object.defineProperty(this, "prevRotateAngle", {
|
|
140
|
+
enumerable: true,
|
|
141
|
+
configurable: true,
|
|
142
|
+
writable: true,
|
|
143
|
+
value: null
|
|
144
|
+
});
|
|
145
|
+
// Tracks how many consecutive frames each hand has been active.
|
|
146
|
+
// Used to require the secondary hand to be stable before escalating
|
|
147
|
+
// the mode (e.g. pan → rotate), preventing a single noisy frame from
|
|
148
|
+
// interrupting an ongoing single-hand gesture.
|
|
149
|
+
Object.defineProperty(this, "leftActiveFrames", {
|
|
150
|
+
enumerable: true,
|
|
151
|
+
configurable: true,
|
|
152
|
+
writable: true,
|
|
153
|
+
value: 0
|
|
154
|
+
});
|
|
155
|
+
Object.defineProperty(this, "rightActiveFrames", {
|
|
156
|
+
enumerable: true,
|
|
157
|
+
configurable: true,
|
|
158
|
+
writable: true,
|
|
159
|
+
value: 0
|
|
160
|
+
});
|
|
130
161
|
this.panSmoother = new EMAPoint(tuning.smoothingAlpha);
|
|
131
162
|
this.zoomSmoother = new EMAScalar(tuning.smoothingAlpha);
|
|
163
|
+
this.rotateSmoother = new EMAScalar(tuning.smoothingAlpha);
|
|
132
164
|
}
|
|
133
165
|
getMode() {
|
|
134
166
|
return this.mode;
|
|
@@ -138,13 +170,35 @@ export class GestureStateMachine {
|
|
|
138
170
|
const { actionDwellMs, releaseGraceMs } = this.tuning;
|
|
139
171
|
const { leftHand, rightHand } = frame;
|
|
140
172
|
// ── Determine desired mode for this frame ─────────────────────────────────
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
173
|
+
// Both fist and pinch trigger the same modes — users can choose either.
|
|
174
|
+
const isActive = (hand) => hand !== null && (hand.gesture === 'fist' || hand.gesture === 'pinch');
|
|
175
|
+
const rightActive = isActive(rightHand);
|
|
176
|
+
const leftActive = isActive(leftHand);
|
|
177
|
+
// Track consecutive active frames per hand. Used to guard against a single
|
|
178
|
+
// noisy frame from the secondary hand escalating (or interrupting) an
|
|
179
|
+
// ongoing single-hand gesture. Counts reset to 0 the moment a hand drops.
|
|
180
|
+
this.leftActiveFrames = leftActive ? this.leftActiveFrames + 1 : 0;
|
|
181
|
+
this.rightActiveFrames = rightActive ? this.rightActiveFrames + 1 : 0;
|
|
182
|
+
// Escalation to 'rotating' requires both hands to have been active for at
|
|
183
|
+
// least ESCALATION_FRAMES consecutive frames. This prevents one brief noisy
|
|
184
|
+
// frame on the secondary hand from interrupting an ongoing pan or zoom.
|
|
185
|
+
const bothStable = this.leftActiveFrames >= GestureStateMachine.ESCALATION_FRAMES &&
|
|
186
|
+
this.rightActiveFrames >= GestureStateMachine.ESCALATION_FRAMES;
|
|
187
|
+
// Both stable → rotate; right only → zoom; left only → pan.
|
|
188
|
+
// When both hands are active but not yet stable, we preserve the current
|
|
189
|
+
// single-hand mode (panning/zooming) so the secondary hand must be held
|
|
190
|
+
// for ESCALATION_FRAMES before rotating kicks in. This prevents a single
|
|
191
|
+
// noisy frame from the secondary hand from interrupting an ongoing gesture.
|
|
192
|
+
const bothActiveButUnstable = leftActive && rightActive && !bothStable;
|
|
193
|
+
const desired = bothStable
|
|
194
|
+
? 'rotating'
|
|
195
|
+
: bothActiveButUnstable && (this.mode === 'panning' || this.mode === 'zooming')
|
|
196
|
+
? this.mode // hold current mode until secondary hand is confirmed stable
|
|
197
|
+
: rightActive
|
|
198
|
+
? 'zooming'
|
|
199
|
+
: leftActive
|
|
200
|
+
? 'panning'
|
|
201
|
+
: 'idle';
|
|
148
202
|
// ── idle ──────────────────────────────────────────────────────────────────
|
|
149
203
|
if (this.mode === 'idle') {
|
|
150
204
|
if (desired !== 'idle') {
|
|
@@ -158,10 +212,15 @@ export class GestureStateMachine {
|
|
|
158
212
|
else {
|
|
159
213
|
this.actionDwell = null;
|
|
160
214
|
}
|
|
161
|
-
return this.buildOutput(null, null);
|
|
215
|
+
return this.buildOutput(null, null, null);
|
|
162
216
|
}
|
|
163
217
|
// ── panning ───────────────────────────────────────────────────────────────
|
|
164
218
|
if (this.mode === 'panning') {
|
|
219
|
+
// Escalate to rotating once the right hand becomes stably active too.
|
|
220
|
+
if (bothStable) {
|
|
221
|
+
this.transitionTo('rotating');
|
|
222
|
+
return this.buildOutput(null, null, null);
|
|
223
|
+
}
|
|
165
224
|
if (desired !== 'panning') {
|
|
166
225
|
if (this.releaseTimer === null) {
|
|
167
226
|
this.releaseTimer = now;
|
|
@@ -169,13 +228,13 @@ export class GestureStateMachine {
|
|
|
169
228
|
else if (now - this.releaseTimer >= releaseGraceMs) {
|
|
170
229
|
this.transitionTo('idle');
|
|
171
230
|
}
|
|
172
|
-
return this.buildOutput(null, null);
|
|
231
|
+
return this.buildOutput(null, null, null);
|
|
173
232
|
}
|
|
174
233
|
this.releaseTimer = null;
|
|
175
|
-
const fistHand = leftHand
|
|
234
|
+
const fistHand = isActive(leftHand) ? leftHand : null;
|
|
176
235
|
if (!fistHand) {
|
|
177
236
|
this.transitionTo('idle');
|
|
178
|
-
return this.buildOutput(null, null);
|
|
237
|
+
return this.buildOutput(null, null, null);
|
|
179
238
|
}
|
|
180
239
|
const wrist = fistHand.landmarks[0];
|
|
181
240
|
const smooth = this.panSmoother.update(wrist.x, wrist.y);
|
|
@@ -189,10 +248,15 @@ export class GestureStateMachine {
|
|
|
189
248
|
}
|
|
190
249
|
}
|
|
191
250
|
this.prevPanPos = smooth;
|
|
192
|
-
return this.buildOutput(panDelta, null);
|
|
251
|
+
return this.buildOutput(panDelta, null, null);
|
|
193
252
|
}
|
|
194
253
|
// ── zooming ───────────────────────────────────────────────────────────────
|
|
195
254
|
if (this.mode === 'zooming') {
|
|
255
|
+
// Escalate to rotating once the left hand becomes stably active too.
|
|
256
|
+
if (bothStable) {
|
|
257
|
+
this.transitionTo('rotating');
|
|
258
|
+
return this.buildOutput(null, null, null);
|
|
259
|
+
}
|
|
196
260
|
if (desired !== 'zooming') {
|
|
197
261
|
if (this.releaseTimer === null) {
|
|
198
262
|
this.releaseTimer = now;
|
|
@@ -200,31 +264,76 @@ export class GestureStateMachine {
|
|
|
200
264
|
else if (now - this.releaseTimer >= releaseGraceMs) {
|
|
201
265
|
this.transitionTo('idle');
|
|
202
266
|
}
|
|
203
|
-
return this.buildOutput(null, null);
|
|
267
|
+
return this.buildOutput(null, null, null);
|
|
204
268
|
}
|
|
205
269
|
this.releaseTimer = null;
|
|
206
|
-
if (!
|
|
270
|
+
if (!rightHand) {
|
|
207
271
|
this.transitionTo('idle');
|
|
208
|
-
return this.buildOutput(null, null);
|
|
272
|
+
return this.buildOutput(null, null, null);
|
|
209
273
|
}
|
|
210
|
-
|
|
211
|
-
const
|
|
274
|
+
// Use right wrist vertical position: moving up (lower y) = zoom in, down = zoom out
|
|
275
|
+
const wrist = rightHand.landmarks[0];
|
|
276
|
+
const smoothY = this.zoomSmoother.update(wrist.y);
|
|
212
277
|
let zoomDelta = null;
|
|
213
278
|
if (this.prevZoomDist !== null) {
|
|
214
|
-
const delta =
|
|
279
|
+
const delta = smoothY - this.prevZoomDist;
|
|
215
280
|
if (Math.abs(delta) > this.tuning.zoomDeadzoneRatio) {
|
|
216
|
-
|
|
281
|
+
// Negate: moving hand up (y decreases) → zoom in (positive delta)
|
|
282
|
+
zoomDelta = -delta;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
this.prevZoomDist = smoothY;
|
|
286
|
+
return this.buildOutput(null, zoomDelta, null);
|
|
287
|
+
}
|
|
288
|
+
// ── rotating ─────────────────────────────────────────────────────────────
|
|
289
|
+
if (this.mode === 'rotating') {
|
|
290
|
+
if (desired !== 'rotating') {
|
|
291
|
+
if (this.releaseTimer === null) {
|
|
292
|
+
this.releaseTimer = now;
|
|
293
|
+
}
|
|
294
|
+
else if (now - this.releaseTimer >= releaseGraceMs) {
|
|
295
|
+
this.transitionTo('idle');
|
|
217
296
|
}
|
|
297
|
+
return this.buildOutput(null, null, null);
|
|
218
298
|
}
|
|
219
|
-
this.
|
|
220
|
-
|
|
299
|
+
this.releaseTimer = null;
|
|
300
|
+
if (!leftHand || !rightHand) {
|
|
301
|
+
this.transitionTo('idle');
|
|
302
|
+
return this.buildOutput(null, null, null);
|
|
303
|
+
}
|
|
304
|
+
// Angle of the line from left wrist to right wrist (in radians)
|
|
305
|
+
const lw = leftHand.landmarks[0];
|
|
306
|
+
const rw = rightHand.landmarks[0];
|
|
307
|
+
const rawAngle = Math.atan2(rw.y - lw.y, rw.x - lw.x);
|
|
308
|
+
const smoothAngle = this.rotateSmoother.update(rawAngle);
|
|
309
|
+
let rotateDelta = null;
|
|
310
|
+
if (this.prevRotateAngle !== null) {
|
|
311
|
+
// Wrap the delta to [-π, π] to handle the atan2 discontinuity
|
|
312
|
+
let delta = smoothAngle - this.prevRotateAngle;
|
|
313
|
+
if (delta > Math.PI)
|
|
314
|
+
delta -= 2 * Math.PI;
|
|
315
|
+
if (delta < -Math.PI)
|
|
316
|
+
delta += 2 * Math.PI;
|
|
317
|
+
if (Math.abs(delta) > 0.005) {
|
|
318
|
+
rotateDelta = delta;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
this.prevRotateAngle = smoothAngle;
|
|
322
|
+
return this.buildOutput(null, null, rotateDelta);
|
|
221
323
|
}
|
|
222
|
-
return this.buildOutput(null, null);
|
|
324
|
+
return this.buildOutput(null, null, null);
|
|
223
325
|
}
|
|
224
326
|
transitionTo(next) {
|
|
225
327
|
this.mode = next;
|
|
226
328
|
this.releaseTimer = null;
|
|
227
329
|
this.actionDwell = null;
|
|
330
|
+
// Reset both counters so escalation requires a fresh stable run in the new mode.
|
|
331
|
+
// Exception: keep the dominant hand's counter when entering a single-hand mode so
|
|
332
|
+
// it does not need to re-accumulate from zero if the hand was already stable.
|
|
333
|
+
if (next !== 'panning' && next !== 'rotating')
|
|
334
|
+
this.leftActiveFrames = 0;
|
|
335
|
+
if (next !== 'zooming' && next !== 'rotating')
|
|
336
|
+
this.rightActiveFrames = 0;
|
|
228
337
|
if (next !== 'panning') {
|
|
229
338
|
this.panSmoother.reset();
|
|
230
339
|
this.prevPanPos = null;
|
|
@@ -233,11 +342,21 @@ export class GestureStateMachine {
|
|
|
233
342
|
this.zoomSmoother.reset();
|
|
234
343
|
this.prevZoomDist = null;
|
|
235
344
|
}
|
|
345
|
+
if (next !== 'rotating') {
|
|
346
|
+
this.rotateSmoother.reset();
|
|
347
|
+
this.prevRotateAngle = null;
|
|
348
|
+
}
|
|
236
349
|
}
|
|
237
|
-
buildOutput(panDelta, zoomDelta) {
|
|
238
|
-
return { mode: this.mode, panDelta, zoomDelta };
|
|
350
|
+
buildOutput(panDelta, zoomDelta, rotateDelta) {
|
|
351
|
+
return { mode: this.mode, panDelta, zoomDelta, rotateDelta };
|
|
239
352
|
}
|
|
240
353
|
reset() {
|
|
241
354
|
this.transitionTo('idle');
|
|
242
355
|
}
|
|
243
356
|
}
|
|
357
|
+
Object.defineProperty(GestureStateMachine, "ESCALATION_FRAMES", {
|
|
358
|
+
enumerable: true,
|
|
359
|
+
configurable: true,
|
|
360
|
+
writable: true,
|
|
361
|
+
value: 3
|
|
362
|
+
});
|