@map-gesture-controls/core 0.1.9 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@
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 the left fist, zooms with the right fist, and rotates with both fists, all in the browser via MediaPipe." width="720" />
13
+ <img src="https://raw.githubusercontent.com/sanderdesnaijer/map-gesture-controls/main/docs/public/openlayers-gesture-control-demo-v2.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
@@ -70,7 +70,7 @@ Full TypeScript types are exported for `GestureMode`, `GestureFrame`, `DetectedH
70
70
 
71
71
  ## Gesture recognition
72
72
 
73
- Both **fist** and **pinch** trigger the same map actions users can use whichever is more comfortable.
73
+ Both **fist** and **pinch** trigger the same map actions, users can use whichever is more comfortable.
74
74
 
75
75
  | Gesture | Detection rule | Map action |
76
76
  | --- | --- | --- |
@@ -81,6 +81,8 @@ Both **fist** and **pinch** trigger the same map actions — users can use which
81
81
  | **Both hands active** | Both hands fist or pinch (mixed is fine) | Rotate map |
82
82
  | **Idle** | Anything else | No action |
83
83
 
84
+ > **Reset (OpenLayers only):** `@map-gesture-controls/ol` adds a reset gesture on top of the above. Bring both hands together in a prayer/namaste pose (wrists close, neither hand making a fist or pinch) and hold for 1 second. A progress bar fills in the webcam overlay; when it completes, pan, zoom, and rotation all snap back to their initial values.
85
+
84
86
  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
87
 
86
88
  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.
@@ -170,7 +170,7 @@ export class GestureStateMachine {
170
170
  const { actionDwellMs, releaseGraceMs } = this.tuning;
171
171
  const { leftHand, rightHand } = frame;
172
172
  // ── Determine desired mode for this frame ─────────────────────────────────
173
- // Both fist and pinch trigger the same modes users can choose either.
173
+ // Both fist and pinch trigger the same modes, users can choose either.
174
174
  const isActive = (hand) => hand !== null && (hand.gesture === 'fist' || hand.gesture === 'pinch');
175
175
  const rightActive = isActive(rightHand);
176
176
  const leftActive = isActive(leftHand);
@@ -14,15 +14,19 @@ export declare class WebcamOverlay {
14
14
  private canvas;
15
15
  private ctx;
16
16
  private badge;
17
+ private resetBar;
18
+ private resetFill;
17
19
  private config;
20
+ private lastMode;
18
21
  constructor(config: WebcamConfig);
19
22
  /** Attach video element produced by GestureController */
20
23
  attachVideo(video: HTMLVideoElement): void;
21
24
  /** Mount the overlay into the given parent (usually document.body or map container) */
22
25
  mount(parent: HTMLElement): void;
23
26
  unmount(): void;
24
- /** Called each frame with the latest gesture frame and mode. */
25
- render(frame: GestureFrame | null, mode: GestureMode): void;
27
+ /** Called each frame with the latest gesture frame, mode, and optional reset progress (0 to 1). */
28
+ render(frame: GestureFrame | null, mode: GestureMode, resetProgress?: number): void;
29
+ private updateResetBar;
26
30
  private drawSkeleton;
27
31
  private updateBadge;
28
32
  private applyContainerStyles;
@@ -51,12 +51,30 @@ export class WebcamOverlay {
51
51
  writable: true,
52
52
  value: void 0
53
53
  });
54
+ Object.defineProperty(this, "resetBar", {
55
+ enumerable: true,
56
+ configurable: true,
57
+ writable: true,
58
+ value: void 0
59
+ });
60
+ Object.defineProperty(this, "resetFill", {
61
+ enumerable: true,
62
+ configurable: true,
63
+ writable: true,
64
+ value: void 0
65
+ });
54
66
  Object.defineProperty(this, "config", {
55
67
  enumerable: true,
56
68
  configurable: true,
57
69
  writable: true,
58
70
  value: void 0
59
71
  });
72
+ Object.defineProperty(this, "lastMode", {
73
+ enumerable: true,
74
+ configurable: true,
75
+ writable: true,
76
+ value: null
77
+ });
60
78
  this.config = config;
61
79
  this.container = document.createElement('div');
62
80
  this.container.className = 'ol-gesture-overlay';
@@ -67,8 +85,16 @@ export class WebcamOverlay {
67
85
  this.badge = document.createElement('div');
68
86
  this.badge.className = 'ol-gesture-badge ol-gesture-badge--idle';
69
87
  this.badge.textContent = 'Idle';
88
+ this.resetBar = document.createElement('div');
89
+ this.resetBar.className = 'ol-gesture-reset';
90
+ this.resetBar.innerHTML =
91
+ '<span class="ol-gesture-reset-label">Reset</span>' +
92
+ '<div class="ol-gesture-reset-track"><div class="ol-gesture-reset-fill"></div></div>';
93
+ this.resetBar.style.opacity = '0';
94
+ this.resetFill = this.resetBar.querySelector('.ol-gesture-reset-fill');
70
95
  this.container.appendChild(this.canvas);
71
96
  this.container.appendChild(this.badge);
97
+ this.container.appendChild(this.resetBar);
72
98
  const ctx = this.canvas.getContext('2d');
73
99
  if (!ctx)
74
100
  throw new Error('Cannot get 2D canvas context');
@@ -88,13 +114,16 @@ export class WebcamOverlay {
88
114
  unmount() {
89
115
  this.container.parentElement?.removeChild(this.container);
90
116
  }
91
- /** Called each frame with the latest gesture frame and mode. */
92
- render(frame, mode) {
117
+ /** Called each frame with the latest gesture frame, mode, and optional reset progress (0 to 1). */
118
+ render(frame, mode, resetProgress = 0) {
93
119
  this.updateBadge(mode);
120
+ this.updateResetBar(resetProgress);
94
121
  const w = this.config.width;
95
122
  const h = this.config.height;
96
- this.canvas.width = w;
97
- this.canvas.height = h;
123
+ if (this.canvas.width !== w || this.canvas.height !== h) {
124
+ this.canvas.width = w;
125
+ this.canvas.height = h;
126
+ }
98
127
  this.ctx.clearRect(0, 0, w, h);
99
128
  if (frame === null)
100
129
  return;
@@ -102,6 +131,10 @@ export class WebcamOverlay {
102
131
  this.drawSkeleton(hand.landmarks, mode, hand.gesture === 'fist');
103
132
  }
104
133
  }
134
+ updateResetBar(resetProgress) {
135
+ this.resetFill.style.width = `${resetProgress * 100}%`;
136
+ this.resetBar.style.opacity = resetProgress > 0 ? '1' : '0';
137
+ }
105
138
  drawSkeleton(landmarks, mode, isActionHand) {
106
139
  const { ctx } = this;
107
140
  const w = this.config.width;
@@ -141,6 +174,9 @@ export class WebcamOverlay {
141
174
  }
142
175
  }
143
176
  updateBadge(mode) {
177
+ if (mode === this.lastMode)
178
+ return;
179
+ this.lastMode = mode;
144
180
  const labels = {
145
181
  idle: 'Idle',
146
182
  panning: 'Pan',
package/dist/index.js CHANGED
@@ -29,17 +29,17 @@ const Y = {
29
29
  RING_MCP: 13,
30
30
  PINKY_TIP: 20,
31
31
  PINKY_MCP: 17
32
- }, L = 0.25, z = 0.35, E = [
32
+ }, L = 0.25, z = 0.35, b = [
33
33
  r.INDEX_TIP,
34
34
  r.MIDDLE_TIP,
35
35
  r.RING_TIP,
36
36
  r.PINKY_TIP
37
- ], _ = [
37
+ ], N = [
38
38
  r.INDEX_MCP,
39
39
  r.MIDDLE_MCP,
40
40
  r.RING_MCP,
41
41
  r.PINKY_MCP
42
- ], D = {
42
+ ], w = {
43
43
  idle: "#888888",
44
44
  panning: "#00ccff",
45
45
  zooming: "#00ffcc",
@@ -47,21 +47,21 @@ const Y = {
47
47
  landmark: "rgba(255,255,255,0.6)",
48
48
  connection: "rgba(255,255,255,0.3)",
49
49
  fingertipGlow: "#4488ff"
50
- }, G = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm";
50
+ }, B = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm";
51
51
  function T(i, t) {
52
52
  const e = i.x - t.x, n = i.y - t.y;
53
53
  return Math.sqrt(e * e + n * n);
54
54
  }
55
- function N(i) {
55
+ function _(i) {
56
56
  const t = i[r.WRIST];
57
- for (let s = 0; s < E.length; s++) {
58
- const u = i[E[s]], c = i[_[s]];
57
+ for (let s = 0; s < b.length; s++) {
58
+ const u = i[b[s]], c = i[N[s]];
59
59
  if (T(u, t) < T(c, t) * 0.9)
60
60
  return !1;
61
61
  }
62
- const e = w(i);
62
+ const e = D(i);
63
63
  if (e === 0) return !1;
64
- const n = E.map((s) => i[s]);
64
+ const n = b.map((s) => i[s]);
65
65
  let o = 1 / 0;
66
66
  for (let s = 0; s < n.length - 1; s++) {
67
67
  const u = T(n[s], n[s + 1]);
@@ -72,39 +72,39 @@ function N(i) {
72
72
  function R(i) {
73
73
  const t = i[r.WRIST];
74
74
  let e = 0;
75
- for (let n = 0; n < E.length; n++) {
76
- const o = i[E[n]], a = i[_[n]];
75
+ for (let n = 0; n < b.length; n++) {
76
+ const o = i[b[n]], a = i[N[n]];
77
77
  T(o, t) < T(a, t) * 1.1 && e++;
78
78
  }
79
79
  return e >= 3;
80
80
  }
81
- function w(i) {
81
+ function D(i) {
82
82
  const t = i[r.WRIST], e = i[r.MIDDLE_MCP];
83
83
  return !t || !e ? 0 : T(t, e);
84
84
  }
85
- function j(i, t) {
85
+ function k(i, t) {
86
86
  const e = i[r.INDEX_TIP], n = t[r.INDEX_TIP];
87
87
  return !e || !n ? 0 : T(e, n);
88
88
  }
89
89
  function F(i) {
90
- const t = w(i);
90
+ const t = D(i);
91
91
  if (t === 0) return !1;
92
92
  const e = i[r.THUMB_TIP], n = i[r.INDEX_TIP];
93
93
  return T(e, n) < t * L;
94
94
  }
95
- function B(i) {
96
- const t = w(i);
95
+ function G(i) {
96
+ const t = D(i);
97
97
  if (t === 0) return !1;
98
98
  const e = i[r.THUMB_TIP], n = i[r.INDEX_TIP];
99
99
  return T(e, n) < t * z;
100
100
  }
101
- function k(i) {
102
- return i.length < 21 ? "none" : R(i) ? "fist" : F(i) ? "pinch" : N(i) ? "openPalm" : "none";
101
+ function j(i) {
102
+ return i.length < 21 ? "none" : R(i) ? "fist" : F(i) ? "pinch" : _(i) ? "openPalm" : "none";
103
103
  }
104
- function C() {
104
+ function M() {
105
105
  let i = !1;
106
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");
107
+ return e.length < 21 ? (i = !1, "none") : R(e) ? (i = !1, "fist") : (i ? i = G(e) : i = F(e), i ? "pinch" : _(e) ? "openPalm" : "none");
108
108
  };
109
109
  }
110
110
  class $ {
@@ -118,8 +118,8 @@ class $ {
118
118
  l(this, "tuning");
119
119
  l(this, "lastVideoTime", -1);
120
120
  // One stateful classifier per hand label; persists pinch hysteresis across frames.
121
- l(this, "leftClassifier", C());
122
- l(this, "rightClassifier", C());
121
+ l(this, "leftClassifier", M());
122
+ l(this, "rightClassifier", M());
123
123
  this.tuning = t, this.onFrame = e;
124
124
  }
125
125
  /**
@@ -127,7 +127,7 @@ class $ {
127
127
  * Returns the video element so the overlay can render it.
128
128
  */
129
129
  async init() {
130
- const t = await H.forVisionTasks(G), { HandLandmarker: e } = await import("@mediapipe/tasks-vision");
130
+ const t = await H.forVisionTasks(B), { HandLandmarker: e } = await import("@mediapipe/tasks-vision");
131
131
  return this.landmarker = await e.createFromOptions(t, {
132
132
  baseOptions: {
133
133
  modelAssetPath: "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task",
@@ -174,8 +174,8 @@ class $ {
174
174
  }
175
175
  buildFrame(t, e) {
176
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);
177
+ var p, g;
178
+ const c = t.handedness[u], d = ((p = c == null ? void 0 : c[0]) == null ? void 0 : p.categoryName) === "Left" ? "Left" : "Right", P = ((g = c == null ? void 0 : c[0]) == null ? void 0 : g.score) ?? 0, f = (d === "Left" ? this.leftClassifier : this.rightClassifier)(s);
179
179
  return { handedness: d, score: P, landmarks: s, gesture: f };
180
180
  }), o = n.find((s) => s.handedness === "Left") ?? null, a = n.find((s) => s.handedness === "Right") ?? null;
181
181
  return { timestamp: e, hands: n, leftHand: o, rightHand: a };
@@ -199,7 +199,7 @@ class U {
199
199
  this.value = null;
200
200
  }
201
201
  }
202
- class M {
202
+ class C {
203
203
  constructor(t) {
204
204
  l(this, "value", null);
205
205
  this.alpha = t;
@@ -211,7 +211,7 @@ class M {
211
211
  this.value = null;
212
212
  }
213
213
  }
214
- const y = class y {
214
+ const E = class E {
215
215
  constructor(t) {
216
216
  l(this, "mode", "idle");
217
217
  l(this, "actionDwell", null);
@@ -228,7 +228,7 @@ const y = class y {
228
228
  // interrupting an ongoing single-hand gesture.
229
229
  l(this, "leftActiveFrames", 0);
230
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);
231
+ this.tuning = t, this.panSmoother = new U(t.smoothingAlpha), this.zoomSmoother = new C(t.smoothingAlpha), this.rotateSmoother = new C(t.smoothingAlpha);
232
232
  }
233
233
  getMode() {
234
234
  return this.mode;
@@ -236,53 +236,53 @@ const y = class y {
236
236
  update(t) {
237
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
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";
239
+ const d = this.leftActiveFrames >= E.ESCALATION_FRAMES && this.rightActiveFrames >= E.ESCALATION_FRAMES, m = d ? "rotating" : h && c && !d && (this.mode === "panning" || this.mode === "zooming") ? this.mode : c ? "zooming" : h ? "panning" : "idle";
240
240
  if (this.mode === "idle")
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);
241
+ return m !== "idle" ? this.actionDwell === null || this.actionDwell.gesture !== m ? this.actionDwell = { gesture: m, startMs: e } : e - this.actionDwell.startMs >= n && this.transitionTo(m) : this.actionDwell = null, this.buildOutput(null, null, null);
242
242
  if (this.mode === "panning") {
243
243
  if (d)
244
244
  return this.transitionTo("rotating"), this.buildOutput(null, null, null);
245
- if (g !== "panning")
245
+ if (m !== "panning")
246
246
  return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= o && this.transitionTo("idle"), this.buildOutput(null, null, null);
247
247
  this.releaseTimer = null;
248
248
  const f = u(a) ? a : null;
249
249
  if (!f)
250
250
  return this.transitionTo("idle"), this.buildOutput(null, null, null);
251
- const m = f.landmarks[0], p = this.panSmoother.update(m.x, m.y);
251
+ const p = f.landmarks[0], g = this.panSmoother.update(p.x, p.y);
252
252
  let I = null;
253
253
  if (this.prevPanPos !== null) {
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 });
254
+ const y = g.x - this.prevPanPos.x, v = g.y - this.prevPanPos.y, A = this.tuning.panDeadzonePx / 640;
255
+ (Math.abs(y) > A || Math.abs(v) > A) && (I = { x: y, y: v });
256
256
  }
257
- return this.prevPanPos = p, this.buildOutput(I, null, null);
257
+ return this.prevPanPos = g, this.buildOutput(I, null, null);
258
258
  }
259
259
  if (this.mode === "zooming") {
260
260
  if (d)
261
261
  return this.transitionTo("rotating"), this.buildOutput(null, null, null);
262
- if (g !== "zooming")
262
+ if (m !== "zooming")
263
263
  return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= o && this.transitionTo("idle"), this.buildOutput(null, null, null);
264
264
  if (this.releaseTimer = null, !s)
265
265
  return this.transitionTo("idle"), this.buildOutput(null, null, null);
266
- const f = s.landmarks[0], m = this.zoomSmoother.update(f.y);
267
- let p = null;
266
+ const f = s.landmarks[0], p = this.zoomSmoother.update(f.y);
267
+ let g = null;
268
268
  if (this.prevZoomDist !== null) {
269
- const I = m - this.prevZoomDist;
270
- Math.abs(I) > this.tuning.zoomDeadzoneRatio && (p = -I);
269
+ const I = p - this.prevZoomDist;
270
+ Math.abs(I) > this.tuning.zoomDeadzoneRatio && (g = -I);
271
271
  }
272
- return this.prevZoomDist = m, this.buildOutput(null, p, null);
272
+ return this.prevZoomDist = p, this.buildOutput(null, g, null);
273
273
  }
274
274
  if (this.mode === "rotating") {
275
- if (g !== "rotating")
275
+ if (m !== "rotating")
276
276
  return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= o && this.transitionTo("idle"), this.buildOutput(null, null, null);
277
277
  if (this.releaseTimer = null, !a || !s)
278
278
  return this.transitionTo("idle"), this.buildOutput(null, null, 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;
279
+ const f = a.landmarks[0], p = s.landmarks[0], g = Math.atan2(p.y - f.y, p.x - f.x), I = this.rotateSmoother.update(g);
280
+ let y = null;
281
281
  if (this.prevRotateAngle !== null) {
282
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);
283
+ v > Math.PI && (v -= 2 * Math.PI), v < -Math.PI && (v += 2 * Math.PI), Math.abs(v) > 5e-3 && (y = v);
284
284
  }
285
- return this.prevRotateAngle = I, this.buildOutput(null, null, b);
285
+ return this.prevRotateAngle = I, this.buildOutput(null, null, y);
286
286
  }
287
287
  return this.buildOutput(null, null, null);
288
288
  }
@@ -296,8 +296,8 @@ const y = class y {
296
296
  this.transitionTo("idle");
297
297
  }
298
298
  };
299
- l(y, "ESCALATION_FRAMES", 3);
300
- let S = y;
299
+ l(E, "ESCALATION_FRAMES", 3);
300
+ let S = E;
301
301
  const X = [
302
302
  [0, 1],
303
303
  [1, 2],
@@ -341,8 +341,11 @@ class q {
341
341
  l(this, "canvas");
342
342
  l(this, "ctx");
343
343
  l(this, "badge");
344
+ l(this, "resetBar");
345
+ l(this, "resetFill");
344
346
  l(this, "config");
345
- this.config = t, this.container = document.createElement("div"), this.container.className = "ol-gesture-overlay", this.applyContainerStyles(), this.canvas = document.createElement("canvas"), this.canvas.className = "ol-gesture-canvas", this.canvas.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;", this.badge = document.createElement("div"), this.badge.className = "ol-gesture-badge ol-gesture-badge--idle", this.badge.textContent = "Idle", this.container.appendChild(this.canvas), this.container.appendChild(this.badge);
347
+ l(this, "lastMode", null);
348
+ this.config = t, this.container = document.createElement("div"), this.container.className = "ol-gesture-overlay", this.applyContainerStyles(), this.canvas = document.createElement("canvas"), this.canvas.className = "ol-gesture-canvas", this.canvas.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;", this.badge = document.createElement("div"), this.badge.className = "ol-gesture-badge ol-gesture-badge--idle", this.badge.textContent = "Idle", this.resetBar = document.createElement("div"), this.resetBar.className = "ol-gesture-reset", this.resetBar.innerHTML = '<span class="ol-gesture-reset-label">Reset</span><div class="ol-gesture-reset-track"><div class="ol-gesture-reset-fill"></div></div>', this.resetBar.style.opacity = "0", this.resetFill = this.resetBar.querySelector(".ol-gesture-reset-fill"), this.container.appendChild(this.canvas), this.container.appendChild(this.badge), this.container.appendChild(this.resetBar);
346
349
  const e = this.canvas.getContext("2d");
347
350
  if (!e) throw new Error("Cannot get 2D canvas context");
348
351
  this.ctx = e;
@@ -359,25 +362,30 @@ class q {
359
362
  var t;
360
363
  (t = this.container.parentElement) == null || t.removeChild(this.container);
361
364
  }
362
- /** Called each frame with the latest gesture frame and mode. */
363
- render(t, e) {
364
- this.updateBadge(e);
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)
367
- for (const a of t.hands)
368
- this.drawSkeleton(a.landmarks, e, a.gesture === "fist");
365
+ /** Called each frame with the latest gesture frame, mode, and optional reset progress (0 to 1). */
366
+ render(t, e, n = 0) {
367
+ this.updateBadge(e), this.updateResetBar(n);
368
+ const o = this.config.width, a = this.config.height;
369
+ if ((this.canvas.width !== o || this.canvas.height !== a) && (this.canvas.width = o, this.canvas.height = a), this.ctx.clearRect(0, 0, o, a), t !== null)
370
+ for (const s of t.hands)
371
+ this.drawSkeleton(s.landmarks, e, s.gesture === "fist");
372
+ }
373
+ updateResetBar(t) {
374
+ this.resetFill.style.width = `${t * 100}%`, this.resetBar.style.opacity = t > 0 ? "1" : "0";
369
375
  }
370
376
  drawSkeleton(t, e, n) {
371
377
  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;
378
+ o.strokeStyle = w.connection, o.lineWidth = 1.5;
373
379
  for (const [h, d] of X)
374
380
  !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
381
  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);
382
+ const d = t[h], P = V.includes(h), m = e !== "idle" && P ? w.fingertipGlow : w.landmark;
383
+ o.beginPath(), o.arc(u(d), c(d), P ? 5 : 3, 0, Math.PI * 2), o.fillStyle = m, o.fill(), e !== "idle" && P && (o.shadowBlur = n ? 12 : 6, o.shadowColor = w.fingertipGlow, o.fill(), o.shadowBlur = 0);
378
384
  }
379
385
  }
380
386
  updateBadge(t) {
387
+ if (t === this.lastMode) return;
388
+ this.lastMode = t;
381
389
  const e = {
382
390
  idle: "Idle",
383
391
  panning: "Pan",
@@ -399,15 +407,15 @@ class q {
399
407
  }
400
408
  }
401
409
  export {
402
- D as COLORS,
410
+ w as COLORS,
403
411
  Z as DEFAULT_TUNING_CONFIG,
404
412
  Y as DEFAULT_WEBCAM_CONFIG,
405
413
  $ as GestureController,
406
414
  S as GestureStateMachine,
407
415
  r as LANDMARKS,
408
416
  q as WebcamOverlay,
409
- k as classifyGesture,
410
- C as createHandClassifier,
411
- w as getHandSize,
412
- j as getTwoHandDistance
417
+ j as classifyGesture,
418
+ M as createHandClassifier,
419
+ D as getHandSize,
420
+ k as getTwoHandDistance
413
421
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@map-gesture-controls/core",
3
- "version": "0.1.9",
3
+ "version": "0.2.1",
4
4
  "description": "Map-agnostic hand gesture detection and state machine (MediaPipe)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1 +0,0 @@
1
- export {};
@@ -1,264 +0,0 @@
1
- import { describe, it, expect, beforeEach } from 'vitest';
2
- import { GestureStateMachine } from './GestureStateMachine.js';
3
- import { LANDMARKS } from './constants.js';
4
- // ─── Helpers ──────────────────────────────────────────────────────────────────
5
- const FAST_TUNING = {
6
- actionDwellMs: 0, // dwell=0: transition fires on the SECOND frame (first sets timer)
7
- releaseGraceMs: 0, // grace=0: idle on the SECOND frame after release
8
- panDeadzonePx: 0,
9
- zoomDeadzoneRatio: 0,
10
- smoothingAlpha: 1, // no smoothing, raw values pass through
11
- minDetectionConfidence: 0.65,
12
- minTrackingConfidence: 0.65,
13
- minPresenceConfidence: 0.60,
14
- };
15
- function makeLandmarks(overrides = {}) {
16
- const lm = Array.from({ length: 21 }, () => ({ x: 0.5, y: 0.5, z: 0 }));
17
- for (const [idx, vals] of Object.entries(overrides)) {
18
- lm[Number(idx)] = { ...lm[Number(idx)], ...vals };
19
- }
20
- return lm;
21
- }
22
- function makeHand(gesture, landmarks = makeLandmarks()) {
23
- return { handedness: 'Right', score: 1, landmarks, gesture };
24
- }
25
- function makeFrame(timestamp, leftHand, rightHand) {
26
- const hands = [leftHand, rightHand].filter(Boolean);
27
- return { timestamp, hands, leftHand, rightHand };
28
- }
29
- /**
30
- * Drive the FSM into 'panning' with dwell=0 (requires two identical frames).
31
- * Pan = left fist only.
32
- */
33
- function enterPanning(fsm, hand = makeHand('fist')) {
34
- fsm.update(makeFrame(0, hand, null)); // starts dwell timer
35
- fsm.update(makeFrame(0, hand, null)); // dwell elapsed → panning
36
- }
37
- /**
38
- * Drive the FSM into 'zooming' with dwell=0 (requires two identical frames).
39
- * Zoom = right fist only.
40
- */
41
- function enterZooming(fsm, right = makeHand('fist')) {
42
- fsm.update(makeFrame(0, null, right));
43
- fsm.update(makeFrame(0, null, right));
44
- }
45
- /**
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).
50
- * Rotate = both fists.
51
- */
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));
60
- fsm.update(makeFrame(0, left, right));
61
- fsm.update(makeFrame(0, left, right));
62
- }
63
- // ─── Tests ────────────────────────────────────────────────────────────────────
64
- describe('GestureStateMachine', () => {
65
- let fsm;
66
- beforeEach(() => {
67
- fsm = new GestureStateMachine(FAST_TUNING);
68
- });
69
- // ── Initial state ──────────────────────────────────────────────────────────
70
- it('starts in idle mode', () => {
71
- expect(fsm.getMode()).toBe('idle');
72
- });
73
- // ── idle → panning ─────────────────────────────────────────────────────────
74
- it('transitions to panning when left fist is held (two frames, dwell=0)', () => {
75
- const fistHand = makeHand('fist');
76
- fsm.update(makeFrame(0, fistHand, null)); // starts dwell
77
- const out = fsm.update(makeFrame(0, fistHand, null)); // 0 >= 0 → panning
78
- expect(out.mode).toBe('panning');
79
- });
80
- // ── idle → zooming ─────────────────────────────────────────────────────────
81
- it('transitions to zooming when right fist is held (two frames, dwell=0)', () => {
82
- const right = makeHand('fist');
83
- fsm.update(makeFrame(0, null, right));
84
- const out = fsm.update(makeFrame(0, null, right));
85
- expect(out.mode).toBe('zooming');
86
- });
87
- // ── idle → rotating ────────────────────────────────────────────────────────
88
- it('transitions to rotating when both fists are held stably (5 frames: dwell + 3 escalation)', () => {
89
- const left = makeHand('fist');
90
- const right = makeHand('fist');
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));
95
- const out = fsm.update(makeFrame(0, left, right));
96
- expect(out.mode).toBe('rotating');
97
- });
98
- it('prefers rotating over zooming once both fists are held stably', () => {
99
- const left = makeHand('fist');
100
- const right = makeHand('fist');
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));
105
- const out = fsm.update(makeFrame(0, left, right));
106
- // both fists stable for ESCALATION_FRAMES = rotating, not zooming
107
- expect(out.mode).toBe('rotating');
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
- });
119
- // ── panning → idle ─────────────────────────────────────────────────────────
120
- it('returns to idle when the left fist is released (releaseGraceMs=0)', () => {
121
- enterPanning(fsm);
122
- expect(fsm.getMode()).toBe('panning');
123
- fsm.update(makeFrame(1, null, null)); // starts release timer
124
- const out = fsm.update(makeFrame(1, null, null)); // 0 >= 0 → idle
125
- expect(out.mode).toBe('idle');
126
- });
127
- // ── zooming → idle ─────────────────────────────────────────────────────────
128
- it('returns to idle when right fist is released (releaseGraceMs=0)', () => {
129
- enterZooming(fsm);
130
- expect(fsm.getMode()).toBe('zooming');
131
- fsm.update(makeFrame(1, null, null)); // starts release timer
132
- const out = fsm.update(makeFrame(1, null, null)); // 0 >= 0 → idle
133
- expect(out.mode).toBe('idle');
134
- });
135
- // ── rotating → idle ────────────────────────────────────────────────────────
136
- it('returns to idle when both fists are released (releaseGraceMs=0)', () => {
137
- enterRotating(fsm);
138
- expect(fsm.getMode()).toBe('rotating');
139
- fsm.update(makeFrame(1, null, null)); // starts release timer
140
- const out = fsm.update(makeFrame(1, null, null)); // 0 >= 0 → idle
141
- expect(out.mode).toBe('idle');
142
- });
143
- // ── panDelta output ────────────────────────────────────────────────────────
144
- it('emits no panDelta on the first panning frame (no previous position)', () => {
145
- const fistHand = makeHand('fist');
146
- fsm.update(makeFrame(0, fistHand, null)); // dwell starts
147
- const out = fsm.update(makeFrame(0, fistHand, null)); // enters panning, sets prevPos
148
- expect(out.panDelta).toBeNull();
149
- });
150
- it('emits panDelta once a previous position is established', () => {
151
- const wristPos1 = { x: 0.3, y: 0.4, z: 0 };
152
- const wristPos2 = { x: 0.35, y: 0.45, z: 0 };
153
- const lm1 = makeLandmarks({ [LANDMARKS.WRIST]: wristPos1 });
154
- const lm2 = makeLandmarks({ [LANDMARKS.WRIST]: wristPos2 });
155
- // Frame 1: start dwell (idle)
156
- fsm.update(makeFrame(0, makeHand('fist', lm1), null));
157
- // Frame 2: transition fires → panning, but buildOutput returns from idle branch (panDelta=null)
158
- fsm.update(makeFrame(0, makeHand('fist', lm1), null));
159
- // Frame 3: first real panning frame, sets prevPanPos = wristPos1, no delta yet
160
- fsm.update(makeFrame(1, makeHand('fist', lm1), null));
161
- // Frame 4: second panning frame, emits delta
162
- const out = fsm.update(makeFrame(2, makeHand('fist', lm2), null));
163
- expect(out.mode).toBe('panning');
164
- expect(out.panDelta).not.toBeNull();
165
- expect(out.panDelta.x).toBeCloseTo(wristPos2.x - wristPos1.x);
166
- expect(out.panDelta.y).toBeCloseTo(wristPos2.y - wristPos1.y);
167
- });
168
- // ── zoomDelta output ───────────────────────────────────────────────────────
169
- it('emits no zoomDelta on the first zooming frame', () => {
170
- const right = makeHand('fist');
171
- fsm.update(makeFrame(0, null, right));
172
- const out = fsm.update(makeFrame(0, null, right)); // enters zooming, sets prevZoomDist
173
- expect(out.zoomDelta).toBeNull();
174
- });
175
- it('emits zoomDelta once a previous position is established', () => {
176
- // Right wrist starts lower (y=0.6), then moves up (y=0.4) → zoom in (positive delta)
177
- const lmR1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.6, z: 0 } });
178
- const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.4, z: 0 } });
179
- // Frame 1+2: dwell + transition (zooming entered on frame 2, but output comes from idle branch)
180
- fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
181
- fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
182
- // Frame 3: first real zooming frame, sets prevZoomDist, no delta yet
183
- fsm.update(makeFrame(1, null, makeHand('fist', lmR1)));
184
- // Frame 4: hand moved up → zoom in (positive delta)
185
- const out = fsm.update(makeFrame(2, null, makeHand('fist', lmR2)));
186
- expect(out.mode).toBe('zooming');
187
- expect(out.zoomDelta).not.toBeNull();
188
- expect(out.zoomDelta).toBeGreaterThan(0); // hand moved up = zoom in
189
- });
190
- it('emits negative zoomDelta when hand moves down (zoom out)', () => {
191
- const lmR1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.4, z: 0 } });
192
- const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.5, y: 0.6, z: 0 } });
193
- fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
194
- fsm.update(makeFrame(0, null, makeHand('fist', lmR1)));
195
- fsm.update(makeFrame(1, null, makeHand('fist', lmR1)));
196
- const out = fsm.update(makeFrame(2, null, makeHand('fist', lmR2)));
197
- expect(out.zoomDelta).not.toBeNull();
198
- expect(out.zoomDelta).toBeLessThan(0); // hand moved down = zoom out
199
- });
200
- // ── rotateDelta output ─────────────────────────────────────────────────────
201
- it('emits no rotateDelta on the first rotating frame', () => {
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')));
208
- expect(out.rotateDelta).toBeNull();
209
- });
210
- it('emits rotateDelta once a previous angle is established', () => {
211
- // Left wrist at (0.2, 0.5), right wrist at (0.8, 0.5) → angle = 0 (horizontal)
212
- const lmL1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.2, y: 0.5, z: 0 } });
213
- const lmR1 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.8, y: 0.5, z: 0 } });
214
- // Tilt clockwise: right wrist drops, left wrist rises → angle increases
215
- const lmL2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.2, y: 0.4, z: 0 } });
216
- const lmR2 = makeLandmarks({ [LANDMARKS.WRIST]: { x: 0.8, y: 0.6, z: 0 } });
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
222
- fsm.update(makeFrame(1, makeHand('fist', lmL1), makeHand('fist', lmR1)));
223
- // Second frame: emits delta
224
- const out = fsm.update(makeFrame(2, makeHand('fist', lmL2), makeHand('fist', lmR2)));
225
- expect(out.mode).toBe('rotating');
226
- expect(out.rotateDelta).not.toBeNull();
227
- });
228
- // ── reset ──────────────────────────────────────────────────────────────────
229
- it('reset() returns the FSM to idle', () => {
230
- enterPanning(fsm);
231
- expect(fsm.getMode()).toBe('panning');
232
- fsm.reset();
233
- expect(fsm.getMode()).toBe('idle');
234
- });
235
- it('reset() returns the FSM to idle from rotating', () => {
236
- enterRotating(fsm);
237
- expect(fsm.getMode()).toBe('rotating');
238
- fsm.reset();
239
- expect(fsm.getMode()).toBe('idle');
240
- });
241
- // ── dwell timer ────────────────────────────────────────────────────────────
242
- it('does NOT transition before actionDwellMs elapses', () => {
243
- const slowFsm = new GestureStateMachine({ ...FAST_TUNING, actionDwellMs: 200 });
244
- const fistHand = makeHand('fist');
245
- const out1 = slowFsm.update(makeFrame(0, fistHand, null));
246
- expect(out1.mode).toBe('idle');
247
- const out2 = slowFsm.update(makeFrame(100, fistHand, null)); // 100 ms < 200 ms
248
- expect(out2.mode).toBe('idle');
249
- const out3 = slowFsm.update(makeFrame(200, fistHand, null)); // 200 ms >= 200 ms
250
- expect(out3.mode).toBe('panning');
251
- });
252
- // ── release grace period ───────────────────────────────────────────────────
253
- it('stays in panning during release grace period', () => {
254
- const graceFsm = new GestureStateMachine({ ...FAST_TUNING, releaseGraceMs: 100 });
255
- enterPanning(graceFsm);
256
- expect(graceFsm.getMode()).toBe('panning');
257
- const out1 = graceFsm.update(makeFrame(1, null, null)); // grace starts
258
- expect(out1.mode).toBe('panning');
259
- const out2 = graceFsm.update(makeFrame(50, null, null)); // 50 ms < 100 ms
260
- expect(out2.mode).toBe('panning');
261
- const out3 = graceFsm.update(makeFrame(101, null, null)); // 101 ms >= 100 ms
262
- expect(out3.mode).toBe('idle');
263
- });
264
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,98 +0,0 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { classifyGesture, getTwoHandDistance } from './gestureClassifier.js';
3
- import { LANDMARKS } from './constants.js';
4
- /**
5
- * Build a flat array of 21 landmarks, all at (0.5, 0.5, 0).
6
- * Individual landmarks can be overridden via the `overrides` map.
7
- */
8
- function makeLandmarks(overrides = {}) {
9
- const lm = Array.from({ length: 21 }, () => ({ x: 0.5, y: 0.5, z: 0 }));
10
- for (const [idx, vals] of Object.entries(overrides)) {
11
- lm[Number(idx)] = { ...lm[Number(idx)], ...vals };
12
- }
13
- return lm;
14
- }
15
- /**
16
- * Build an open-palm hand.
17
- *
18
- * Strategy: place wrist at origin, MCP joints at y=0.3, fingertips at y=0.1
19
- * (tips are farther from the wrist than MCPs) and spread them horizontally
20
- * so adjacent-tip distance >> 18% of hand size.
21
- *
22
- * Hand size = dist(wrist, middle-MCP) = dist({0,0},{0,0.3}) = 0.3
23
- * Adjacent tip spread = 0.1 per finger → 0.1 > 0.3 * 0.18 = 0.054 ✓
24
- */
25
- function makeOpenPalmLandmarks() {
26
- const lm = makeLandmarks();
27
- // Wrist at origin
28
- lm[LANDMARKS.WRIST] = { x: 0.5, y: 0.8, z: 0 };
29
- // MCP joints
30
- lm[LANDMARKS.INDEX_MCP] = { x: 0.3, y: 0.5, z: 0 };
31
- lm[LANDMARKS.MIDDLE_MCP] = { x: 0.4, y: 0.5, z: 0 };
32
- lm[LANDMARKS.RING_MCP] = { x: 0.5, y: 0.5, z: 0 };
33
- lm[LANDMARKS.PINKY_MCP] = { x: 0.6, y: 0.5, z: 0 };
34
- // Fingertips: extended (farther from wrist than MCPs) and spread
35
- lm[LANDMARKS.INDEX_TIP] = { x: 0.2, y: 0.1, z: 0 };
36
- lm[LANDMARKS.MIDDLE_TIP] = { x: 0.35, y: 0.1, z: 0 };
37
- lm[LANDMARKS.RING_TIP] = { x: 0.5, y: 0.1, z: 0 };
38
- lm[LANDMARKS.PINKY_TIP] = { x: 0.65, y: 0.1, z: 0 };
39
- return lm;
40
- }
41
- /**
42
- * Build a fist hand.
43
- *
44
- * Strategy: wrist at (0.5, 0.8), MCP joints at (0.5, 0.5),
45
- * fingertips curled back to (0.5, 0.7), closer to wrist than MCPs.
46
- *
47
- * dist(tip, wrist) ≈ 0.1 < dist(mcp, wrist) * 1.1 ≈ 0.33 ✓
48
- */
49
- function makeFistLandmarks() {
50
- const lm = makeLandmarks();
51
- lm[LANDMARKS.WRIST] = { x: 0.5, y: 0.8, z: 0 };
52
- lm[LANDMARKS.INDEX_MCP] = { x: 0.4, y: 0.5, z: 0 };
53
- lm[LANDMARKS.MIDDLE_MCP] = { x: 0.45, y: 0.5, z: 0 };
54
- lm[LANDMARKS.RING_MCP] = { x: 0.5, y: 0.5, z: 0 };
55
- lm[LANDMARKS.PINKY_MCP] = { x: 0.55, y: 0.5, z: 0 };
56
- lm[LANDMARKS.INDEX_TIP] = { x: 0.4, y: 0.72, z: 0 };
57
- lm[LANDMARKS.MIDDLE_TIP] = { x: 0.45, y: 0.72, z: 0 };
58
- lm[LANDMARKS.RING_TIP] = { x: 0.5, y: 0.72, z: 0 };
59
- lm[LANDMARKS.PINKY_TIP] = { x: 0.55, y: 0.72, z: 0 };
60
- return lm;
61
- }
62
- // ─── classifyGesture ──────────────────────────────────────────────────────────
63
- describe('classifyGesture', () => {
64
- it('returns "none" when fewer than 21 landmarks are provided', () => {
65
- expect(classifyGesture([])).toBe('none');
66
- expect(classifyGesture(makeLandmarks().slice(0, 20))).toBe('none');
67
- });
68
- it('classifies an open palm as "openPalm"', () => {
69
- expect(classifyGesture(makeOpenPalmLandmarks())).toBe('openPalm');
70
- });
71
- it('classifies a fist as "fist"', () => {
72
- expect(classifyGesture(makeFistLandmarks())).toBe('fist');
73
- });
74
- it('returns "none" for an ambiguous / neutral hand', () => {
75
- // All landmarks at the same point, fingers neither extended nor curled
76
- expect(classifyGesture(makeLandmarks())).toBe('none');
77
- });
78
- it('prefers "fist" over "openPalm" when both criteria match', () => {
79
- // The function checks fist first, so a fist should never become openPalm.
80
- const fist = makeFistLandmarks();
81
- expect(classifyGesture(fist)).toBe('fist');
82
- });
83
- });
84
- // ─── getTwoHandDistance ───────────────────────────────────────────────────────
85
- describe('getTwoHandDistance', () => {
86
- it('returns the Euclidean distance between the two index fingertips', () => {
87
- const handA = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.0, y: 0.0, z: 0 } });
88
- const handB = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.3, y: 0.4, z: 0 } });
89
- expect(getTwoHandDistance(handA, handB)).toBeCloseTo(0.5);
90
- });
91
- it('returns 0 when the index fingertips are at the same position', () => {
92
- const hand = makeLandmarks({ [LANDMARKS.INDEX_TIP]: { x: 0.5, y: 0.5, z: 0 } });
93
- expect(getTwoHandDistance(hand, hand)).toBe(0);
94
- });
95
- it('returns 0 when landmarks array is empty (missing tips)', () => {
96
- expect(getTwoHandDistance([], [])).toBe(0);
97
- });
98
- });