@map-gesture-controls/core 0.1.8 → 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 CHANGED
@@ -16,7 +16,7 @@
16
16
  ## What it does
17
17
 
18
18
  - Detects hands and classifies gestures at 30+ fps using MediaPipe Hand Landmarker
19
- - Recognizes **fist** (left = pan, right = zoom, both = rotate) and **idle** states
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
@@ -59,7 +59,8 @@ controller.start();
59
59
  | `GestureController` | Class | Opens the webcam, runs MediaPipe detection, and emits gesture frames |
60
60
  | `GestureStateMachine` | Class | Manages gesture state transitions with dwell and grace timers |
61
61
  | `WebcamOverlay` | Class | Renders a configurable camera preview overlay |
62
- | `classifyGesture` | Function | Classifies a set of hand landmarks into `fist` or `none` |
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) |
63
64
  | `getHandSize` | Function | Computes the bounding size of a hand from its landmarks |
64
65
  | `getTwoHandDistance` | Function | Measures the distance between two detected hands |
65
66
  | `DEFAULT_WEBCAM_CONFIG` | Constant | Default webcam overlay settings |
@@ -69,13 +70,19 @@ Full TypeScript types are exported for `GestureMode`, `GestureFrame`, `DetectedH
69
70
 
70
71
  ## Gesture recognition
71
72
 
72
- | Gesture | Detection rule | Use case |
73
+ Both **fist** and **pinch** trigger the same map actions — users can use whichever is more comfortable.
74
+
75
+ | Gesture | Detection rule | Map action |
73
76
  | --- | --- | --- |
74
77
  | **Left fist** | Left hand, 3+ fingers curled | Pan / drag |
75
- | **Right fist** | Right hand, 3+ fingers curled | Zoom in/out (vertical movement) |
76
- | **Both fists** | Both hands, 3+ fingers curled each | Rotate map |
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 |
77
82
  | **Idle** | Anything else | No action |
78
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
+
79
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.
80
87
 
81
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 { classifyGesture } from './gestureClassifier.js';
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 gesture = classifyGesture(landmarks);
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;
@@ -33,6 +33,9 @@ export declare class GestureStateMachine {
33
33
  private prevZoomDist;
34
34
  private rotateSmoother;
35
35
  private prevRotateAngle;
36
+ private leftActiveFrames;
37
+ private rightActiveFrames;
38
+ private static readonly ESCALATION_FRAMES;
36
39
  constructor(tuning: TuningConfig);
37
40
  getMode(): GestureMode;
38
41
  update(frame: GestureFrame): StateMachineOutput;
@@ -142,6 +142,22 @@ export class GestureStateMachine {
142
142
  writable: true,
143
143
  value: null
144
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
+ });
145
161
  this.panSmoother = new EMAPoint(tuning.smoothingAlpha);
146
162
  this.zoomSmoother = new EMAScalar(tuning.smoothingAlpha);
147
163
  this.rotateSmoother = new EMAScalar(tuning.smoothingAlpha);
@@ -154,17 +170,35 @@ export class GestureStateMachine {
154
170
  const { actionDwellMs, releaseGraceMs } = this.tuning;
155
171
  const { leftHand, rightHand } = frame;
156
172
  // ── Determine desired mode for this frame ─────────────────────────────────
157
- const rightFist = rightHand !== null && rightHand.gesture === 'fist';
158
- const leftFist = leftHand !== null && leftHand.gesture === 'fist';
159
- const bothFists = rightFist && leftFist;
160
- // Both fists → rotate; right only → zoom; left only → pan
161
- const desired = bothFists
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
162
194
  ? 'rotating'
163
- : rightFist
164
- ? 'zooming'
165
- : leftFist
166
- ? 'panning'
167
- : 'idle';
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';
168
202
  // ── idle ──────────────────────────────────────────────────────────────────
169
203
  if (this.mode === 'idle') {
170
204
  if (desired !== 'idle') {
@@ -182,6 +216,11 @@ export class GestureStateMachine {
182
216
  }
183
217
  // ── panning ───────────────────────────────────────────────────────────────
184
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
+ }
185
224
  if (desired !== 'panning') {
186
225
  if (this.releaseTimer === null) {
187
226
  this.releaseTimer = now;
@@ -192,7 +231,7 @@ export class GestureStateMachine {
192
231
  return this.buildOutput(null, null, null);
193
232
  }
194
233
  this.releaseTimer = null;
195
- const fistHand = leftHand?.gesture === 'fist' ? leftHand : null;
234
+ const fistHand = isActive(leftHand) ? leftHand : null;
196
235
  if (!fistHand) {
197
236
  this.transitionTo('idle');
198
237
  return this.buildOutput(null, null, null);
@@ -213,6 +252,11 @@ export class GestureStateMachine {
213
252
  }
214
253
  // ── zooming ───────────────────────────────────────────────────────────────
215
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
+ }
216
260
  if (desired !== 'zooming') {
217
261
  if (this.releaseTimer === null) {
218
262
  this.releaseTimer = now;
@@ -283,6 +327,13 @@ export class GestureStateMachine {
283
327
  this.mode = next;
284
328
  this.releaseTimer = null;
285
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;
286
337
  if (next !== 'panning') {
287
338
  this.panSmoother.reset();
288
339
  this.prevPanPos = null;
@@ -303,3 +354,9 @@ export class GestureStateMachine {
303
354
  this.transitionTo('idle');
304
355
  }
305
356
  }
357
+ Object.defineProperty(GestureStateMachine, "ESCALATION_FRAMES", {
358
+ enumerable: true,
359
+ configurable: true,
360
+ writable: true,
361
+ value: 3
362
+ });
@@ -43,10 +43,20 @@ function enterZooming(fsm, right = makeHand('fist')) {
43
43
  fsm.update(makeFrame(0, null, right));
44
44
  }
45
45
  /**
46
- * Drive the FSM into 'rotating' with dwell=0 (requires two identical frames).
46
+ * Drive the FSM into 'rotating' with dwell=0.
47
+ * Requires ESCALATION_FRAMES (3) consecutive both-active frames, then one
48
+ * more for the dwell to fire (dwell=0 means it fires on the frame that meets
49
+ * the threshold, so 3 stable frames + 1 dwell frame = 4 total).
47
50
  * Rotate = both fists.
48
51
  */
49
52
  function enterRotating(fsm, left = makeHand('fist'), right = makeHand('fist')) {
53
+ // Frame 1: both active, dwell timer starts (idle branch, no transition yet).
54
+ // Frame 2: dwell fires → transitionTo('zooming'), resets leftActiveFrames to 0.
55
+ // Frames 3-5: leftActiveFrames counts 1 → 2 → 3 (= ESCALATION_FRAMES).
56
+ // Frame 5: bothStable=true → zooming branch escalates to rotating.
57
+ fsm.update(makeFrame(0, left, right));
58
+ fsm.update(makeFrame(0, left, right));
59
+ fsm.update(makeFrame(0, left, right));
50
60
  fsm.update(makeFrame(0, left, right));
51
61
  fsm.update(makeFrame(0, left, right));
52
62
  }
@@ -75,21 +85,37 @@ describe('GestureStateMachine', () => {
75
85
  expect(out.mode).toBe('zooming');
76
86
  });
77
87
  // ── idle → rotating ────────────────────────────────────────────────────────
78
- it('transitions to rotating when both fists are held (two frames, dwell=0)', () => {
88
+ it('transitions to rotating when both fists are held stably (5 frames: dwell + 3 escalation)', () => {
79
89
  const left = makeHand('fist');
80
90
  const right = makeHand('fist');
81
91
  fsm.update(makeFrame(0, left, right));
92
+ fsm.update(makeFrame(0, left, right));
93
+ fsm.update(makeFrame(0, left, right));
94
+ fsm.update(makeFrame(0, left, right));
82
95
  const out = fsm.update(makeFrame(0, left, right));
83
96
  expect(out.mode).toBe('rotating');
84
97
  });
85
- it('prefers rotating over zooming when both fists are held', () => {
98
+ it('prefers rotating over zooming once both fists are held stably', () => {
86
99
  const left = makeHand('fist');
87
100
  const right = makeHand('fist');
88
101
  fsm.update(makeFrame(0, left, right));
102
+ fsm.update(makeFrame(0, left, right));
103
+ fsm.update(makeFrame(0, left, right));
104
+ fsm.update(makeFrame(0, left, right));
89
105
  const out = fsm.update(makeFrame(0, left, right));
90
- // both fists = rotating, not zooming
106
+ // both fists stable for ESCALATION_FRAMES = rotating, not zooming
91
107
  expect(out.mode).toBe('rotating');
92
108
  });
109
+ it('does NOT transition to rotating if second hand only appears for 1-2 frames (noise guard)', () => {
110
+ const left = makeHand('fist');
111
+ const right = makeHand('fist');
112
+ // Only 2 frames with both hands — below ESCALATION_FRAMES threshold
113
+ fsm.update(makeFrame(0, left, null)); // establish left pan
114
+ fsm.update(makeFrame(0, left, null)); // → panning
115
+ fsm.update(makeFrame(1, left, right)); // right appears — 1 frame, not stable yet
116
+ const out = fsm.update(makeFrame(1, left, right)); // 2 frames — still below threshold
117
+ expect(out.mode).toBe('panning');
118
+ });
93
119
  // ── panning → idle ─────────────────────────────────────────────────────────
94
120
  it('returns to idle when the left fist is released (releaseGraceMs=0)', () => {
95
121
  enterPanning(fsm);
@@ -173,10 +199,12 @@ describe('GestureStateMachine', () => {
173
199
  });
174
200
  // ── rotateDelta output ─────────────────────────────────────────────────────
175
201
  it('emits no rotateDelta on the first rotating frame', () => {
176
- const left = makeHand('fist');
177
- const right = makeHand('fist');
178
- fsm.update(makeFrame(0, left, right));
179
- const out = fsm.update(makeFrame(0, left, right)); // enters rotating, sets prevRotateAngle
202
+ // enterRotating drives into rotating mode (5 frames); the escalation frame
203
+ // itself returns null (no prevAngle yet). The *next* frame sets prevAngle,
204
+ // still no delta. Only the frame after that can emit a delta.
205
+ enterRotating(fsm);
206
+ // First full frame inside rotating — sets prevRotateAngle, no delta yet
207
+ const out = fsm.update(makeFrame(1, makeHand('fist'), makeHand('fist')));
180
208
  expect(out.rotateDelta).toBeNull();
181
209
  });
182
210
  it('emits rotateDelta once a previous angle is established', () => {
@@ -186,9 +214,13 @@ describe('GestureStateMachine', () => {
186
214
  // Tilt clockwise: right wrist drops, left wrist rises → angle increases
187
215
  const lmL2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.2, y: 0.4, z: 0 } });
188
216
  const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.8, y: 0.6, z: 0 } });
189
- fsm.update(makeFrame(0, makeHand('fist', lmL1), makeHand('fist', lmR1)));
190
- fsm.update(makeFrame(0, makeHand('fist', lmL1), makeHand('fist', lmR1)));
217
+ // Get into rotating mode (5 frames with lmL1/lmR1)
218
+ for (let i = 0; i < 5; i++) {
219
+ fsm.update(makeFrame(0, makeHand('fist', lmL1), makeHand('fist', lmR1)));
220
+ }
221
+ // First frame inside rotating: sets prevRotateAngle, no delta
191
222
  fsm.update(makeFrame(1, makeHand('fist', lmL1), makeHand('fist', lmR1)));
223
+ // Second frame: emits delta
192
224
  const out = fsm.update(makeFrame(2, makeHand('fist', lmL2), makeHand('fist', lmR2)));
193
225
  expect(out.mode).toBe('rotating');
194
226
  expect(out.rotateDelta).not.toBeNull();
@@ -13,6 +13,8 @@ export declare const LANDMARKS: {
13
13
  readonly PINKY_TIP: 20;
14
14
  readonly PINKY_MCP: 17;
15
15
  };
16
+ export declare const PINCH_THRESHOLD = 0.25;
17
+ export declare const PINCH_RELEASE_THRESHOLD = 0.35;
16
18
  export declare const FINGERTIP_INDICES: readonly [8, 12, 16, 20];
17
19
  export declare const FINGER_BASE_INDICES: readonly [5, 9, 13, 17];
18
20
  export declare const COLORS: {
package/dist/constants.js CHANGED
@@ -29,6 +29,12 @@ export const LANDMARKS = {
29
29
  PINKY_TIP: 20,
30
30
  PINKY_MCP: 17,
31
31
  };
32
+ // Pinch detection thresholds as a fraction of hand size.
33
+ // Hysteresis: enter pinch at PINCH_THRESHOLD, exit (release) at PINCH_RELEASE_THRESHOLD.
34
+ // The wider release threshold prevents the classifier from flickering between
35
+ // 'pinch' and 'none' when the fingers hover at the edge of the enter threshold.
36
+ export const PINCH_THRESHOLD = 0.25;
37
+ export const PINCH_RELEASE_THRESHOLD = 0.35;
32
38
  // Fingertip indices for open-palm detection
33
39
  export const FINGERTIP_INDICES = [
34
40
  LANDMARKS.INDEX_TIP,
@@ -14,9 +14,24 @@ export declare function getTwoHandDistance(landmarksA: HandLandmark[], landmarks
14
14
  /**
15
15
  * Classify the gesture for a single hand from its landmarks.
16
16
  *
17
- * Priority: fist > openPalm > none
17
+ * Priority: fist > pinch > openPalm > none
18
18
  *
19
- * Fist: most fingers curled
20
- * Open palm: all fingers extended
19
+ * Fist: most fingers curled
20
+ * Pinch: thumb tip and index tip close together
21
+ * Open palm: all fingers extended and spread
22
+ *
23
+ * NOTE: This stateless version has no pinch hysteresis. Use `createHandClassifier`
24
+ * for a stateful per-hand classifier that prevents pinch flickering.
21
25
  */
22
26
  export declare function classifyGesture(landmarks: HandLandmark[]): GestureType;
27
+ /**
28
+ * Returns a stateful classify function for a single hand.
29
+ *
30
+ * Pinch uses hysteresis: the hand enters 'pinch' when tips are within
31
+ * PINCH_THRESHOLD of hand size, and stays in 'pinch' until tips separate
32
+ * beyond PINCH_RELEASE_THRESHOLD. This prevents rapid toggling when fingers
33
+ * hover at the entry threshold during a held pinch gesture.
34
+ *
35
+ * Use one instance per hand (left / right) and reset between hand sessions.
36
+ */
37
+ export declare function createHandClassifier(): (landmarks: HandLandmark[]) => GestureType;
@@ -1,4 +1,4 @@
1
- import { LANDMARKS, FINGERTIP_INDICES, FINGER_BASE_INDICES } from './constants.js';
1
+ import { LANDMARKS, FINGERTIP_INDICES, FINGER_BASE_INDICES, PINCH_THRESHOLD, PINCH_RELEASE_THRESHOLD } from './constants.js';
2
2
  /**
3
3
  * Euclidean distance between two landmarks (ignoring Z).
4
4
  */
@@ -89,13 +89,42 @@ export function getTwoHandDistance(landmarksA, landmarksB) {
89
89
  return 0;
90
90
  return dist(tipA, tipB);
91
91
  }
92
+ /**
93
+ * Stateless pinch check used only as the entry condition.
94
+ * Returns true when thumb tip and index tip are within PINCH_THRESHOLD of hand size.
95
+ */
96
+ function isPinchEnter(landmarks) {
97
+ const handSize = getHandSize(landmarks);
98
+ if (handSize === 0)
99
+ return false;
100
+ const thumbTip = landmarks[LANDMARKS.THUMB_TIP];
101
+ const indexTip = landmarks[LANDMARKS.INDEX_TIP];
102
+ return dist(thumbTip, indexTip) < handSize * PINCH_THRESHOLD;
103
+ }
104
+ /**
105
+ * Returns true when thumb tip and index tip are still within PINCH_RELEASE_THRESHOLD.
106
+ * Used as the exit condition to implement hysteresis: once pinching, we stay in
107
+ * 'pinch' until the fingers open wider than the release threshold.
108
+ */
109
+ function isPinchHeld(landmarks) {
110
+ const handSize = getHandSize(landmarks);
111
+ if (handSize === 0)
112
+ return false;
113
+ const thumbTip = landmarks[LANDMARKS.THUMB_TIP];
114
+ const indexTip = landmarks[LANDMARKS.INDEX_TIP];
115
+ return dist(thumbTip, indexTip) < handSize * PINCH_RELEASE_THRESHOLD;
116
+ }
92
117
  /**
93
118
  * Classify the gesture for a single hand from its landmarks.
94
119
  *
95
- * Priority: fist > openPalm > none
120
+ * Priority: fist > pinch > openPalm > none
121
+ *
122
+ * Fist: most fingers curled
123
+ * Pinch: thumb tip and index tip close together
124
+ * Open palm: all fingers extended and spread
96
125
  *
97
- * Fist: most fingers curled
98
- * Open palm: all fingers extended
126
+ * NOTE: This stateless version has no pinch hysteresis. Use `createHandClassifier`
127
+ * for a stateful per-hand classifier that prevents pinch flickering.
99
128
  */
100
129
  export function classifyGesture(landmarks) {
101
130
  if (landmarks.length < 21)
@@ -103,8 +132,48 @@ export function classifyGesture(landmarks) {
103
132
  if (areAllFingersCurled(landmarks)) {
104
133
  return 'fist';
105
134
  }
135
+ if (isPinchEnter(landmarks)) {
136
+ return 'pinch';
137
+ }
106
138
  if (areAllFingersExtended(landmarks)) {
107
139
  return 'openPalm';
108
140
  }
109
141
  return 'none';
110
142
  }
143
+ /**
144
+ * Returns a stateful classify function for a single hand.
145
+ *
146
+ * Pinch uses hysteresis: the hand enters 'pinch' when tips are within
147
+ * PINCH_THRESHOLD of hand size, and stays in 'pinch' until tips separate
148
+ * beyond PINCH_RELEASE_THRESHOLD. This prevents rapid toggling when fingers
149
+ * hover at the entry threshold during a held pinch gesture.
150
+ *
151
+ * Use one instance per hand (left / right) and reset between hand sessions.
152
+ */
153
+ export function createHandClassifier() {
154
+ let pinching = false;
155
+ return function classify(landmarks) {
156
+ if (landmarks.length < 21) {
157
+ pinching = false;
158
+ return 'none';
159
+ }
160
+ if (areAllFingersCurled(landmarks)) {
161
+ pinching = false;
162
+ return 'fist';
163
+ }
164
+ // Hysteresis: enter pinch on PINCH_THRESHOLD, exit on PINCH_RELEASE_THRESHOLD.
165
+ if (pinching) {
166
+ pinching = isPinchHeld(landmarks);
167
+ }
168
+ else {
169
+ pinching = isPinchEnter(landmarks);
170
+ }
171
+ if (pinching) {
172
+ return 'pinch';
173
+ }
174
+ if (areAllFingersExtended(landmarks)) {
175
+ return 'openPalm';
176
+ }
177
+ return 'none';
178
+ };
179
+ }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,6 @@ export { GestureController } from './GestureController.js';
2
2
  export { GestureStateMachine } from './GestureStateMachine.js';
3
3
  export type { StateMachineOutput } from './GestureStateMachine.js';
4
4
  export { WebcamOverlay } from './WebcamOverlay.js';
5
- export { classifyGesture, getHandSize, getTwoHandDistance } from './gestureClassifier.js';
5
+ export { classifyGesture, createHandClassifier, getHandSize, getTwoHandDistance } from './gestureClassifier.js';
6
6
  export type { GestureMode, GestureType, HandednessLabel, GestureFrame, DetectedHand, WebcamConfig, TuningConfig, SmoothedPoint, Point2D, HandLandmark, } from './types.js';
7
7
  export { DEFAULT_WEBCAM_CONFIG, DEFAULT_TUNING_CONFIG, LANDMARKS, COLORS, } from './constants.js';
package/dist/index.js CHANGED
@@ -1,15 +1,15 @@
1
- var E = Object.defineProperty;
2
- var M = (o, t, e) => t in o ? E(o, t, { enumerable: !0, configurable: !0, writable: !0, value: e }) : o[t] = e;
3
- var l = (o, t, e) => M(o, typeof t != "symbol" ? t + "" : t, e);
4
- import { FilesetResolver as C } from "@mediapipe/tasks-vision";
5
- const L = {
1
+ var O = Object.defineProperty;
2
+ var x = (i, t, e) => t in i ? O(i, t, { enumerable: !0, configurable: !0, writable: !0, value: e }) : i[t] = e;
3
+ var l = (i, t, e) => x(i, typeof t != "symbol" ? t + "" : t, e);
4
+ import { FilesetResolver as H } from "@mediapipe/tasks-vision";
5
+ const Y = {
6
6
  enabled: !0,
7
7
  mode: "corner",
8
8
  opacity: 0.85,
9
9
  position: "bottom-right",
10
10
  width: 320,
11
11
  height: 240
12
- }, H = {
12
+ }, Z = {
13
13
  actionDwellMs: 80,
14
14
  releaseGraceMs: 150,
15
15
  panDeadzonePx: 10,
@@ -29,17 +29,17 @@ const L = {
29
29
  RING_MCP: 13,
30
30
  PINKY_TIP: 20,
31
31
  PINKY_MCP: 17
32
- }, P = [
32
+ }, L = 0.25, z = 0.35, E = [
33
33
  r.INDEX_TIP,
34
34
  r.MIDDLE_TIP,
35
35
  r.RING_TIP,
36
36
  r.PINKY_TIP
37
- ], b = [
37
+ ], _ = [
38
38
  r.INDEX_MCP,
39
39
  r.MIDDLE_MCP,
40
40
  r.RING_MCP,
41
41
  r.PINKY_MCP
42
- ], y = {
42
+ ], D = {
43
43
  idle: "#888888",
44
44
  panning: "#00ccff",
45
45
  zooming: "#00ffcc",
@@ -47,49 +47,67 @@ const L = {
47
47
  landmark: "rgba(255,255,255,0.6)",
48
48
  connection: "rgba(255,255,255,0.3)",
49
49
  fingertipGlow: "#4488ff"
50
- }, _ = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm";
51
- function T(o, t) {
52
- const e = o.x - t.x, i = o.y - t.y;
53
- return Math.sqrt(e * e + i * i);
50
+ }, G = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm";
51
+ function T(i, t) {
52
+ const e = i.x - t.x, n = i.y - t.y;
53
+ return Math.sqrt(e * e + n * n);
54
54
  }
55
- function N(o) {
56
- const t = o[r.WRIST];
57
- for (let n = 0; n < P.length; n++) {
58
- const d = o[P[n]], c = o[b[n]];
59
- if (T(d, t) < T(c, t) * 0.9)
55
+ function N(i) {
56
+ const t = i[r.WRIST];
57
+ for (let s = 0; s < E.length; s++) {
58
+ const u = i[E[s]], c = i[_[s]];
59
+ if (T(u, t) < T(c, t) * 0.9)
60
60
  return !1;
61
61
  }
62
- const e = x(o);
62
+ const e = w(i);
63
63
  if (e === 0) return !1;
64
- const i = P.map((n) => o[n]);
65
- let s = 1 / 0;
66
- for (let n = 0; n < i.length - 1; n++) {
67
- const d = T(i[n], i[n + 1]);
68
- d < s && (s = d);
64
+ const n = E.map((s) => i[s]);
65
+ let o = 1 / 0;
66
+ for (let s = 0; s < n.length - 1; s++) {
67
+ const u = T(n[s], n[s + 1]);
68
+ u < o && (o = u);
69
69
  }
70
- return s >= e * 0.18;
70
+ return o >= e * 0.18;
71
71
  }
72
- function S(o) {
73
- const t = o[r.WRIST];
72
+ function R(i) {
73
+ const t = i[r.WRIST];
74
74
  let e = 0;
75
- for (let i = 0; i < P.length; i++) {
76
- const s = o[P[i]], a = o[b[i]];
77
- T(s, t) < T(a, t) * 1.1 && e++;
75
+ for (let n = 0; n < E.length; n++) {
76
+ const o = i[E[n]], a = i[_[n]];
77
+ T(o, t) < T(a, t) * 1.1 && e++;
78
78
  }
79
79
  return e >= 3;
80
80
  }
81
- function x(o) {
82
- const t = o[r.WRIST], e = o[r.MIDDLE_MCP];
81
+ function w(i) {
82
+ const t = i[r.WRIST], e = i[r.MIDDLE_MCP];
83
83
  return !t || !e ? 0 : T(t, e);
84
84
  }
85
- function k(o, t) {
86
- const e = o[r.INDEX_TIP], i = t[r.INDEX_TIP];
87
- return !e || !i ? 0 : T(e, i);
85
+ function j(i, t) {
86
+ const e = i[r.INDEX_TIP], n = t[r.INDEX_TIP];
87
+ return !e || !n ? 0 : T(e, n);
88
88
  }
89
- function R(o) {
90
- return o.length < 21 ? "none" : S(o) ? "fist" : N(o) ? "openPalm" : "none";
89
+ function F(i) {
90
+ const t = w(i);
91
+ if (t === 0) return !1;
92
+ const e = i[r.THUMB_TIP], n = i[r.INDEX_TIP];
93
+ return T(e, n) < t * L;
91
94
  }
92
- class B {
95
+ function B(i) {
96
+ const t = w(i);
97
+ if (t === 0) return !1;
98
+ const e = i[r.THUMB_TIP], n = i[r.INDEX_TIP];
99
+ return T(e, n) < t * z;
100
+ }
101
+ function k(i) {
102
+ return i.length < 21 ? "none" : R(i) ? "fist" : F(i) ? "pinch" : N(i) ? "openPalm" : "none";
103
+ }
104
+ function C() {
105
+ let i = !1;
106
+ return function(e) {
107
+ return e.length < 21 ? (i = !1, "none") : R(e) ? (i = !1, "fist") : (i ? i = B(e) : i = F(e), i ? "pinch" : N(e) ? "openPalm" : "none");
108
+ };
109
+ }
110
+ class $ {
93
111
  constructor(t, e) {
94
112
  l(this, "landmarker", null);
95
113
  l(this, "videoEl", null);
@@ -99,6 +117,9 @@ class B {
99
117
  l(this, "onFrame");
100
118
  l(this, "tuning");
101
119
  l(this, "lastVideoTime", -1);
120
+ // One stateful classifier per hand label; persists pinch hysteresis across frames.
121
+ l(this, "leftClassifier", C());
122
+ l(this, "rightClassifier", C());
102
123
  this.tuning = t, this.onFrame = e;
103
124
  }
104
125
  /**
@@ -106,7 +127,7 @@ class B {
106
127
  * Returns the video element so the overlay can render it.
107
128
  */
108
129
  async init() {
109
- const t = await C.forVisionTasks(_), { HandLandmarker: e } = await import("@mediapipe/tasks-vision");
130
+ const t = await H.forVisionTasks(G), { HandLandmarker: e } = await import("@mediapipe/tasks-vision");
110
131
  return this.landmarker = await e.createFromOptions(t, {
111
132
  baseOptions: {
112
133
  modelAssetPath: "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task",
@@ -119,8 +140,8 @@ class B {
119
140
  minTrackingConfidence: this.tuning.minTrackingConfidence
120
141
  }), this.videoEl = document.createElement("video"), this.videoEl.setAttribute("playsinline", ""), this.videoEl.setAttribute("autoplay", ""), this.videoEl.muted = !0, this.videoEl.width = 640, this.videoEl.height = 480, this.stream = await navigator.mediaDevices.getUserMedia({
121
142
  video: { width: 640, height: 480, facingMode: "user" }
122
- }), this.videoEl.srcObject = this.stream, await new Promise((i) => {
123
- this.videoEl.addEventListener("loadeddata", () => i(), { once: !0 });
143
+ }), this.videoEl.srcObject = this.stream, await new Promise((n) => {
144
+ this.videoEl.addEventListener("loadeddata", () => n(), { once: !0 });
124
145
  }), this.videoEl;
125
146
  }
126
147
  start() {
@@ -131,7 +152,7 @@ class B {
131
152
  }
132
153
  destroy() {
133
154
  var t, e;
134
- this.stop(), (t = this.stream) == null || t.getTracks().forEach((i) => i.stop()), (e = this.landmarker) == null || e.close(), this.landmarker = null, this.videoEl = null, this.stream = null;
155
+ this.stop(), (t = this.stream) == null || t.getTracks().forEach((n) => n.stop()), (e = this.landmarker) == null || e.close(), this.landmarker = null, this.videoEl = null, this.stream = null;
135
156
  }
136
157
  loop() {
137
158
  this.running && (this.rafHandle = requestAnimationFrame(() => this.loop()), this.processFrame());
@@ -139,31 +160,31 @@ class B {
139
160
  processFrame() {
140
161
  const t = this.videoEl, e = this.landmarker;
141
162
  if (!t || !e || t.readyState < 2) return;
142
- const i = performance.now();
163
+ const n = performance.now();
143
164
  if (t.currentTime === this.lastVideoTime) return;
144
165
  this.lastVideoTime = t.currentTime;
145
- let s;
166
+ let o;
146
167
  try {
147
- s = e.detectForVideo(t, i);
168
+ o = e.detectForVideo(t, n);
148
169
  } catch {
149
170
  return;
150
171
  }
151
- const a = this.buildFrame(s, i);
172
+ const a = this.buildFrame(o, n);
152
173
  this.onFrame(a);
153
174
  }
154
175
  buildFrame(t, e) {
155
- const i = t.landmarks.map((n, d) => {
156
- var p, g;
157
- const c = t.handedness[d], h = ((p = c == null ? void 0 : c[0]) == null ? void 0 : p.categoryName) === "Left" ? "Left" : "Right", m = ((g = c == null ? void 0 : c[0]) == null ? void 0 : g.score) ?? 0, f = R(n);
158
- return { handedness: h, score: m, landmarks: n, gesture: f };
159
- }), s = i.find((n) => n.handedness === "Left") ?? null, a = i.find((n) => n.handedness === "Right") ?? null;
160
- return { timestamp: e, hands: i, leftHand: s, rightHand: a };
176
+ const n = t.landmarks.map((s, u) => {
177
+ var m, p;
178
+ const c = t.handedness[u], d = ((m = c == null ? void 0 : c[0]) == null ? void 0 : m.categoryName) === "Left" ? "Left" : "Right", P = ((p = c == null ? void 0 : c[0]) == null ? void 0 : p.score) ?? 0, f = (d === "Left" ? this.leftClassifier : this.rightClassifier)(s);
179
+ return { handedness: d, score: P, landmarks: s, gesture: f };
180
+ }), o = n.find((s) => s.handedness === "Left") ?? null, a = n.find((s) => s.handedness === "Right") ?? null;
181
+ return { timestamp: e, hands: n, leftHand: o, rightHand: a };
161
182
  }
162
183
  getVideoElement() {
163
184
  return this.videoEl;
164
185
  }
165
186
  }
166
- class A {
187
+ class U {
167
188
  constructor(t) {
168
189
  l(this, "value", null);
169
190
  this.alpha = t;
@@ -178,7 +199,7 @@ class A {
178
199
  this.value = null;
179
200
  }
180
201
  }
181
- class D {
202
+ class M {
182
203
  constructor(t) {
183
204
  l(this, "value", null);
184
205
  this.alpha = t;
@@ -190,7 +211,7 @@ class D {
190
211
  this.value = null;
191
212
  }
192
213
  }
193
- class U {
214
+ const y = class y {
194
215
  constructor(t) {
195
216
  l(this, "mode", "idle");
196
217
  l(this, "actionDwell", null);
@@ -201,69 +222,83 @@ class U {
201
222
  l(this, "prevZoomDist", null);
202
223
  l(this, "rotateSmoother");
203
224
  l(this, "prevRotateAngle", null);
204
- this.tuning = t, this.panSmoother = new A(t.smoothingAlpha), this.zoomSmoother = new D(t.smoothingAlpha), this.rotateSmoother = new D(t.smoothingAlpha);
225
+ // Tracks how many consecutive frames each hand has been active.
226
+ // Used to require the secondary hand to be stable before escalating
227
+ // the mode (e.g. pan → rotate), preventing a single noisy frame from
228
+ // interrupting an ongoing single-hand gesture.
229
+ l(this, "leftActiveFrames", 0);
230
+ l(this, "rightActiveFrames", 0);
231
+ this.tuning = t, this.panSmoother = new U(t.smoothingAlpha), this.zoomSmoother = new M(t.smoothingAlpha), this.rotateSmoother = new M(t.smoothingAlpha);
205
232
  }
206
233
  getMode() {
207
234
  return this.mode;
208
235
  }
209
236
  update(t) {
210
- const e = t.timestamp, { actionDwellMs: i, releaseGraceMs: s } = this.tuning, { leftHand: a, rightHand: n } = t, d = n !== null && n.gesture === "fist", c = a !== null && a.gesture === "fist", h = d && c ? "rotating" : d ? "zooming" : c ? "panning" : "idle";
237
+ const e = t.timestamp, { actionDwellMs: n, releaseGraceMs: o } = this.tuning, { leftHand: a, rightHand: s } = t, u = (f) => f !== null && (f.gesture === "fist" || f.gesture === "pinch"), c = u(s), h = u(a);
238
+ this.leftActiveFrames = h ? this.leftActiveFrames + 1 : 0, this.rightActiveFrames = c ? this.rightActiveFrames + 1 : 0;
239
+ const d = this.leftActiveFrames >= y.ESCALATION_FRAMES && this.rightActiveFrames >= y.ESCALATION_FRAMES, g = d ? "rotating" : h && c && !d && (this.mode === "panning" || this.mode === "zooming") ? this.mode : c ? "zooming" : h ? "panning" : "idle";
211
240
  if (this.mode === "idle")
212
- return h !== "idle" ? this.actionDwell === null || this.actionDwell.gesture !== h ? this.actionDwell = { gesture: h, startMs: e } : e - this.actionDwell.startMs >= i && this.transitionTo(h) : this.actionDwell = null, this.buildOutput(null, null, null);
241
+ return g !== "idle" ? this.actionDwell === null || this.actionDwell.gesture !== g ? this.actionDwell = { gesture: g, startMs: e } : e - this.actionDwell.startMs >= n && this.transitionTo(g) : this.actionDwell = null, this.buildOutput(null, null, null);
213
242
  if (this.mode === "panning") {
214
- if (h !== "panning")
215
- return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= s && this.transitionTo("idle"), this.buildOutput(null, null, null);
243
+ if (d)
244
+ return this.transitionTo("rotating"), this.buildOutput(null, null, null);
245
+ if (g !== "panning")
246
+ return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= o && this.transitionTo("idle"), this.buildOutput(null, null, null);
216
247
  this.releaseTimer = null;
217
- const m = (a == null ? void 0 : a.gesture) === "fist" ? a : null;
218
- if (!m)
248
+ const f = u(a) ? a : null;
249
+ if (!f)
219
250
  return this.transitionTo("idle"), this.buildOutput(null, null, null);
220
- const f = m.landmarks[0], p = this.panSmoother.update(f.x, f.y);
221
- let g = null;
251
+ const m = f.landmarks[0], p = this.panSmoother.update(m.x, m.y);
252
+ let I = null;
222
253
  if (this.prevPanPos !== null) {
223
- const I = p.x - this.prevPanPos.x, v = p.y - this.prevPanPos.y, w = this.tuning.panDeadzonePx / 640;
224
- (Math.abs(I) > w || Math.abs(v) > w) && (g = { x: I, y: v });
254
+ const b = p.x - this.prevPanPos.x, v = p.y - this.prevPanPos.y, A = this.tuning.panDeadzonePx / 640;
255
+ (Math.abs(b) > A || Math.abs(v) > A) && (I = { x: b, y: v });
225
256
  }
226
- return this.prevPanPos = p, this.buildOutput(g, null, null);
257
+ return this.prevPanPos = p, this.buildOutput(I, null, null);
227
258
  }
228
259
  if (this.mode === "zooming") {
229
- if (h !== "zooming")
230
- return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= s && this.transitionTo("idle"), this.buildOutput(null, null, null);
231
- if (this.releaseTimer = null, !n)
260
+ if (d)
261
+ return this.transitionTo("rotating"), this.buildOutput(null, null, null);
262
+ if (g !== "zooming")
263
+ return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= o && this.transitionTo("idle"), this.buildOutput(null, null, null);
264
+ if (this.releaseTimer = null, !s)
232
265
  return this.transitionTo("idle"), this.buildOutput(null, null, null);
233
- const m = n.landmarks[0], f = this.zoomSmoother.update(m.y);
266
+ const f = s.landmarks[0], m = this.zoomSmoother.update(f.y);
234
267
  let p = null;
235
268
  if (this.prevZoomDist !== null) {
236
- const g = f - this.prevZoomDist;
237
- Math.abs(g) > this.tuning.zoomDeadzoneRatio && (p = -g);
269
+ const I = m - this.prevZoomDist;
270
+ Math.abs(I) > this.tuning.zoomDeadzoneRatio && (p = -I);
238
271
  }
239
- return this.prevZoomDist = f, this.buildOutput(null, p, null);
272
+ return this.prevZoomDist = m, this.buildOutput(null, p, null);
240
273
  }
241
274
  if (this.mode === "rotating") {
242
- if (h !== "rotating")
243
- return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= s && this.transitionTo("idle"), this.buildOutput(null, null, null);
244
- if (this.releaseTimer = null, !a || !n)
275
+ if (g !== "rotating")
276
+ return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= o && this.transitionTo("idle"), this.buildOutput(null, null, null);
277
+ if (this.releaseTimer = null, !a || !s)
245
278
  return this.transitionTo("idle"), this.buildOutput(null, null, null);
246
- const m = a.landmarks[0], f = n.landmarks[0], p = Math.atan2(f.y - m.y, f.x - m.x), g = this.rotateSmoother.update(p);
247
- let I = null;
279
+ const f = a.landmarks[0], m = s.landmarks[0], p = Math.atan2(m.y - f.y, m.x - f.x), I = this.rotateSmoother.update(p);
280
+ let b = null;
248
281
  if (this.prevRotateAngle !== null) {
249
- let v = g - this.prevRotateAngle;
250
- v > Math.PI && (v -= 2 * Math.PI), v < -Math.PI && (v += 2 * Math.PI), Math.abs(v) > 5e-3 && (I = v);
282
+ let v = I - this.prevRotateAngle;
283
+ v > Math.PI && (v -= 2 * Math.PI), v < -Math.PI && (v += 2 * Math.PI), Math.abs(v) > 5e-3 && (b = v);
251
284
  }
252
- return this.prevRotateAngle = g, this.buildOutput(null, null, I);
285
+ return this.prevRotateAngle = I, this.buildOutput(null, null, b);
253
286
  }
254
287
  return this.buildOutput(null, null, null);
255
288
  }
256
289
  transitionTo(t) {
257
- this.mode = t, this.releaseTimer = null, this.actionDwell = null, t !== "panning" && (this.panSmoother.reset(), this.prevPanPos = null), t !== "zooming" && (this.zoomSmoother.reset(), this.prevZoomDist = null), t !== "rotating" && (this.rotateSmoother.reset(), this.prevRotateAngle = null);
290
+ this.mode = t, this.releaseTimer = null, this.actionDwell = null, t !== "panning" && t !== "rotating" && (this.leftActiveFrames = 0), t !== "zooming" && t !== "rotating" && (this.rightActiveFrames = 0), t !== "panning" && (this.panSmoother.reset(), this.prevPanPos = null), t !== "zooming" && (this.zoomSmoother.reset(), this.prevZoomDist = null), t !== "rotating" && (this.rotateSmoother.reset(), this.prevRotateAngle = null);
258
291
  }
259
- buildOutput(t, e, i) {
260
- return { mode: this.mode, panDelta: t, zoomDelta: e, rotateDelta: i };
292
+ buildOutput(t, e, n) {
293
+ return { mode: this.mode, panDelta: t, zoomDelta: e, rotateDelta: n };
261
294
  }
262
295
  reset() {
263
296
  this.transitionTo("idle");
264
297
  }
265
- }
266
- const F = [
298
+ };
299
+ l(y, "ESCALATION_FRAMES", 3);
300
+ let S = y;
301
+ const X = [
267
302
  [0, 1],
268
303
  [1, 2],
269
304
  [2, 3],
@@ -293,14 +328,14 @@ const F = [
293
328
  [9, 13],
294
329
  [13, 17]
295
330
  // palm cross
296
- ], O = [
331
+ ], V = [
297
332
  r.THUMB_TIP,
298
333
  r.INDEX_TIP,
299
334
  r.MIDDLE_TIP,
300
335
  r.RING_TIP,
301
336
  r.PINKY_TIP
302
337
  ];
303
- class V {
338
+ class q {
304
339
  constructor(t) {
305
340
  l(this, "container");
306
341
  l(this, "canvas");
@@ -327,19 +362,19 @@ class V {
327
362
  /** Called each frame with the latest gesture frame and mode. */
328
363
  render(t, e) {
329
364
  this.updateBadge(e);
330
- const i = this.config.width, s = this.config.height;
331
- if (this.canvas.width = i, this.canvas.height = s, this.ctx.clearRect(0, 0, i, s), t !== null)
365
+ const n = this.config.width, o = this.config.height;
366
+ if (this.canvas.width = n, this.canvas.height = o, this.ctx.clearRect(0, 0, n, o), t !== null)
332
367
  for (const a of t.hands)
333
368
  this.drawSkeleton(a.landmarks, e, a.gesture === "fist");
334
369
  }
335
- drawSkeleton(t, e, i) {
336
- const { ctx: s } = this, a = this.config.width, n = this.config.height, d = (u) => (1 - u.x) * a, c = (u) => u.y * n;
337
- s.strokeStyle = y.connection, s.lineWidth = 1.5;
338
- for (const [u, h] of F)
339
- !t[u] || !t[h] || (s.beginPath(), s.moveTo(d(t[u]), c(t[u])), s.lineTo(d(t[h]), c(t[h])), s.stroke());
340
- for (let u = 0; u < t.length; u++) {
341
- const h = t[u], m = O.includes(u), f = e !== "idle" && m ? y.fingertipGlow : y.landmark;
342
- s.beginPath(), s.arc(d(h), c(h), m ? 5 : 3, 0, Math.PI * 2), s.fillStyle = f, s.fill(), e !== "idle" && m && (s.shadowBlur = i ? 12 : 6, s.shadowColor = y.fingertipGlow, s.fill(), s.shadowBlur = 0);
370
+ drawSkeleton(t, e, n) {
371
+ const { ctx: o } = this, a = this.config.width, s = this.config.height, u = (h) => (1 - h.x) * a, c = (h) => h.y * s;
372
+ o.strokeStyle = D.connection, o.lineWidth = 1.5;
373
+ for (const [h, d] of X)
374
+ !t[h] || !t[d] || (o.beginPath(), o.moveTo(u(t[h]), c(t[h])), o.lineTo(u(t[d]), c(t[d])), o.stroke());
375
+ for (let h = 0; h < t.length; h++) {
376
+ const d = t[h], P = V.includes(h), g = e !== "idle" && P ? D.fingertipGlow : D.landmark;
377
+ o.beginPath(), o.arc(u(d), c(d), P ? 5 : 3, 0, Math.PI * 2), o.fillStyle = g, o.fill(), e !== "idle" && P && (o.shadowBlur = n ? 12 : 6, o.shadowColor = D.fingertipGlow, o.fill(), o.shadowBlur = 0);
343
378
  }
344
379
  }
345
380
  updateBadge(t) {
@@ -352,11 +387,11 @@ class V {
352
387
  this.badge.textContent = e[t], this.badge.className = `ol-gesture-badge ol-gesture-badge--${t}`;
353
388
  }
354
389
  applyContainerStyles() {
355
- const { mode: t, position: e, width: i, height: s, opacity: a } = this.config;
390
+ const { mode: t, position: e, width: n, height: o, opacity: a } = this.config;
356
391
  if (this.container.style.cssText = "", this.container.style.position = "fixed", this.container.style.zIndex = "9999", this.container.style.overflow = "hidden", this.container.style.borderRadius = "8px", this.container.style.opacity = String(a), this.container.style.display = t === "hidden" ? "none" : "block", t === "corner") {
357
- this.container.style.width = `${i}px`, this.container.style.height = `${s}px`;
358
- const n = "16px";
359
- e === "bottom-right" ? (this.container.style.bottom = n, this.container.style.right = n) : e === "bottom-left" ? (this.container.style.bottom = n, this.container.style.left = n) : e === "top-right" ? (this.container.style.top = n, this.container.style.right = n) : (this.container.style.top = n, this.container.style.left = n);
392
+ this.container.style.width = `${n}px`, this.container.style.height = `${o}px`;
393
+ const s = "16px";
394
+ e === "bottom-right" ? (this.container.style.bottom = s, this.container.style.right = s) : e === "bottom-left" ? (this.container.style.bottom = s, this.container.style.left = s) : e === "top-right" ? (this.container.style.top = s, this.container.style.right = s) : (this.container.style.top = s, this.container.style.left = s);
360
395
  } else t === "full" && (this.container.style.top = "0", this.container.style.left = "0", this.container.style.width = "100vw", this.container.style.height = "100vh", this.container.style.borderRadius = "0");
361
396
  }
362
397
  updateConfig(t) {
@@ -364,14 +399,15 @@ class V {
364
399
  }
365
400
  }
366
401
  export {
367
- y as COLORS,
368
- H as DEFAULT_TUNING_CONFIG,
369
- L as DEFAULT_WEBCAM_CONFIG,
370
- B as GestureController,
371
- U as GestureStateMachine,
402
+ D as COLORS,
403
+ Z as DEFAULT_TUNING_CONFIG,
404
+ Y as DEFAULT_WEBCAM_CONFIG,
405
+ $ as GestureController,
406
+ S as GestureStateMachine,
372
407
  r as LANDMARKS,
373
- V as WebcamOverlay,
374
- R as classifyGesture,
375
- x as getHandSize,
376
- k as getTwoHandDistance
408
+ q as WebcamOverlay,
409
+ k as classifyGesture,
410
+ C as createHandClassifier,
411
+ w as getHandSize,
412
+ j as getTwoHandDistance
377
413
  };
package/dist/types.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export type GestureMode = 'idle' | 'panning' | 'zooming' | 'rotating';
2
2
  export type HandednessLabel = 'Left' | 'Right';
3
- export type GestureType = 'openPalm' | 'fist' | 'none';
3
+ export type GestureType = 'openPalm' | 'fist' | 'pinch' | 'none';
4
4
  export interface Point2D {
5
5
  x: number;
6
6
  y: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@map-gesture-controls/core",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "Map-agnostic hand gesture detection and state machine (MediaPipe)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",