@map-gesture-controls/core 0.1.3

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/dist/index.js ADDED
@@ -0,0 +1,360 @@
1
+ var w = Object.defineProperty;
2
+ var b = (o, t, e) => t in o ? w(o, t, { enumerable: !0, configurable: !0, writable: !0, value: e }) : o[t] = e;
3
+ var a = (o, t, e) => b(o, typeof t != "symbol" ? t + "" : t, e);
4
+ import { FilesetResolver as E } from "@mediapipe/tasks-vision";
5
+ const L = {
6
+ enabled: !0,
7
+ mode: "corner",
8
+ opacity: 0.85,
9
+ position: "bottom-right",
10
+ width: 320,
11
+ height: 240
12
+ }, H = {
13
+ actionDwellMs: 80,
14
+ releaseGraceMs: 150,
15
+ panDeadzonePx: 10,
16
+ zoomDeadzoneRatio: 5e-3,
17
+ smoothingAlpha: 0.5,
18
+ minDetectionConfidence: 0.65,
19
+ minTrackingConfidence: 0.65,
20
+ minPresenceConfidence: 0.6
21
+ }, h = {
22
+ WRIST: 0,
23
+ THUMB_TIP: 4,
24
+ INDEX_TIP: 8,
25
+ INDEX_MCP: 5,
26
+ MIDDLE_TIP: 12,
27
+ MIDDLE_MCP: 9,
28
+ RING_TIP: 16,
29
+ RING_MCP: 13,
30
+ PINKY_TIP: 20,
31
+ PINKY_MCP: 17
32
+ }, I = [
33
+ h.INDEX_TIP,
34
+ h.MIDDLE_TIP,
35
+ h.RING_TIP,
36
+ h.PINKY_TIP
37
+ ], y = [
38
+ h.INDEX_MCP,
39
+ h.MIDDLE_MCP,
40
+ h.RING_MCP,
41
+ h.PINKY_MCP
42
+ ], T = {
43
+ idle: "#888888",
44
+ panning: "#00ccff",
45
+ zooming: "#00ffcc",
46
+ landmark: "rgba(255,255,255,0.6)",
47
+ connection: "rgba(255,255,255,0.3)",
48
+ fingertipGlow: "#4488ff"
49
+ }, C = "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm";
50
+ function g(o, t) {
51
+ const e = o.x - t.x, n = o.y - t.y;
52
+ return Math.sqrt(e * e + n * n);
53
+ }
54
+ function M(o) {
55
+ const t = o[h.WRIST];
56
+ for (let i = 0; i < I.length; i++) {
57
+ const d = o[I[i]], c = o[y[i]];
58
+ if (g(d, t) < g(c, t) * 0.9)
59
+ return !1;
60
+ }
61
+ const e = N(o);
62
+ if (e === 0) return !1;
63
+ const n = I.map((i) => o[i]);
64
+ let s = 1 / 0;
65
+ for (let i = 0; i < n.length - 1; i++) {
66
+ const d = g(n[i], n[i + 1]);
67
+ d < s && (s = d);
68
+ }
69
+ return s >= e * 0.18;
70
+ }
71
+ function _(o) {
72
+ const t = o[h.WRIST];
73
+ let e = 0;
74
+ for (let n = 0; n < I.length; n++) {
75
+ const s = o[I[n]], l = o[y[n]];
76
+ g(s, t) < g(l, t) * 1.1 && e++;
77
+ }
78
+ return e >= 3;
79
+ }
80
+ function N(o) {
81
+ const t = o[h.WRIST], e = o[h.MIDDLE_MCP];
82
+ return !t || !e ? 0 : g(t, e);
83
+ }
84
+ function S(o, t) {
85
+ const e = o[h.INDEX_TIP], n = t[h.INDEX_TIP];
86
+ return !e || !n ? 0 : g(e, n);
87
+ }
88
+ function x(o) {
89
+ return o.length < 21 ? "none" : _(o) ? "fist" : M(o) ? "openPalm" : "none";
90
+ }
91
+ class k {
92
+ constructor(t, e) {
93
+ a(this, "landmarker", null);
94
+ a(this, "videoEl", null);
95
+ a(this, "stream", null);
96
+ a(this, "rafHandle", null);
97
+ a(this, "running", !1);
98
+ a(this, "onFrame");
99
+ a(this, "tuning");
100
+ a(this, "lastVideoTime", -1);
101
+ this.tuning = t, this.onFrame = e;
102
+ }
103
+ /**
104
+ * Initialise MediaPipe and request webcam access.
105
+ * Returns the video element so the overlay can render it.
106
+ */
107
+ async init() {
108
+ const t = await E.forVisionTasks(C), { HandLandmarker: e } = await import("@mediapipe/tasks-vision");
109
+ return this.landmarker = await e.createFromOptions(t, {
110
+ baseOptions: {
111
+ modelAssetPath: "https://storage.googleapis.com/mediapipe-models/hand_landmarker/hand_landmarker/float16/1/hand_landmarker.task",
112
+ delegate: "GPU"
113
+ },
114
+ runningMode: "VIDEO",
115
+ numHands: 2,
116
+ minHandDetectionConfidence: this.tuning.minDetectionConfidence,
117
+ minHandPresenceConfidence: this.tuning.minPresenceConfidence,
118
+ minTrackingConfidence: this.tuning.minTrackingConfidence
119
+ }), 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({
120
+ video: { width: 640, height: 480, facingMode: "user" }
121
+ }), this.videoEl.srcObject = this.stream, await new Promise((n) => {
122
+ this.videoEl.addEventListener("loadeddata", () => n(), { once: !0 });
123
+ }), this.videoEl;
124
+ }
125
+ start() {
126
+ this.running || (this.running = !0, this.loop());
127
+ }
128
+ stop() {
129
+ this.running = !1, this.rafHandle !== null && (cancelAnimationFrame(this.rafHandle), this.rafHandle = null);
130
+ }
131
+ destroy() {
132
+ var t, e;
133
+ 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;
134
+ }
135
+ loop() {
136
+ this.running && (this.rafHandle = requestAnimationFrame(() => this.loop()), this.processFrame());
137
+ }
138
+ processFrame() {
139
+ const t = this.videoEl, e = this.landmarker;
140
+ if (!t || !e || t.readyState < 2) return;
141
+ const n = performance.now();
142
+ if (t.currentTime === this.lastVideoTime) return;
143
+ this.lastVideoTime = t.currentTime;
144
+ let s;
145
+ try {
146
+ s = e.detectForVideo(t, n);
147
+ } catch {
148
+ return;
149
+ }
150
+ const l = this.buildFrame(s, n);
151
+ this.onFrame(l);
152
+ }
153
+ buildFrame(t, e) {
154
+ const n = t.landmarks.map((i, d) => {
155
+ var f, v;
156
+ const c = t.handedness[d], u = ((f = c == null ? void 0 : c[0]) == null ? void 0 : f.categoryName) === "Left" ? "Left" : "Right", m = ((v = c == null ? void 0 : c[0]) == null ? void 0 : v.score) ?? 0, p = x(i);
157
+ return { handedness: u, score: m, landmarks: i, gesture: p };
158
+ }), s = n.find((i) => i.handedness === "Left") ?? null, l = n.find((i) => i.handedness === "Right") ?? null;
159
+ return { timestamp: e, hands: n, leftHand: s, rightHand: l };
160
+ }
161
+ getVideoElement() {
162
+ return this.videoEl;
163
+ }
164
+ }
165
+ class R {
166
+ constructor(t) {
167
+ a(this, "value", null);
168
+ this.alpha = t;
169
+ }
170
+ update(t, e) {
171
+ return this.value === null ? this.value = { x: t, y: e } : this.value = {
172
+ x: this.alpha * t + (1 - this.alpha) * this.value.x,
173
+ y: this.alpha * e + (1 - this.alpha) * this.value.y
174
+ }, this.value;
175
+ }
176
+ reset() {
177
+ this.value = null;
178
+ }
179
+ }
180
+ class F {
181
+ constructor(t) {
182
+ a(this, "value", null);
183
+ this.alpha = t;
184
+ }
185
+ update(t) {
186
+ return this.value === null ? this.value = t : this.value = this.alpha * t + (1 - this.alpha) * this.value, this.value;
187
+ }
188
+ reset() {
189
+ this.value = null;
190
+ }
191
+ }
192
+ class B {
193
+ constructor(t) {
194
+ a(this, "mode", "idle");
195
+ a(this, "actionDwell", null);
196
+ a(this, "releaseTimer", null);
197
+ a(this, "panSmoother");
198
+ a(this, "prevPanPos", null);
199
+ a(this, "zoomSmoother");
200
+ a(this, "prevZoomDist", null);
201
+ this.tuning = t, this.panSmoother = new R(t.smoothingAlpha), this.zoomSmoother = new F(t.smoothingAlpha);
202
+ }
203
+ getMode() {
204
+ return this.mode;
205
+ }
206
+ update(t) {
207
+ const e = t.timestamp, { actionDwellMs: n, releaseGraceMs: s } = this.tuning, { leftHand: l, rightHand: i } = t, d = l !== null && i !== null && l.gesture === "openPalm" && i.gesture === "openPalm", c = l !== null && l.gesture === "fist" && i === null || i !== null && i.gesture === "fist" && l === null, r = d ? "zooming" : c ? "panning" : "idle";
208
+ if (this.mode === "idle")
209
+ return r !== "idle" ? this.actionDwell === null || this.actionDwell.gesture !== r ? this.actionDwell = { gesture: r, startMs: e } : e - this.actionDwell.startMs >= n && this.transitionTo(r) : this.actionDwell = null, this.buildOutput(null, null);
210
+ if (this.mode === "panning") {
211
+ if (r !== "panning")
212
+ return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= s && this.transitionTo("idle"), this.buildOutput(null, null);
213
+ this.releaseTimer = null;
214
+ const u = (l == null ? void 0 : l.gesture) === "fist" ? l : i;
215
+ if (!u)
216
+ return this.transitionTo("idle"), this.buildOutput(null, null);
217
+ const m = u.landmarks[0], p = this.panSmoother.update(m.x, m.y);
218
+ let f = null;
219
+ if (this.prevPanPos !== null) {
220
+ const v = p.x - this.prevPanPos.x, P = p.y - this.prevPanPos.y, D = this.tuning.panDeadzonePx / 640;
221
+ (Math.abs(v) > D || Math.abs(P) > D) && (f = { x: v, y: P });
222
+ }
223
+ return this.prevPanPos = p, this.buildOutput(f, null);
224
+ }
225
+ if (this.mode === "zooming") {
226
+ if (r !== "zooming")
227
+ return this.releaseTimer === null ? this.releaseTimer = e : e - this.releaseTimer >= s && this.transitionTo("idle"), this.buildOutput(null, null);
228
+ if (this.releaseTimer = null, !l || !i)
229
+ return this.transitionTo("idle"), this.buildOutput(null, null);
230
+ const u = S(l.landmarks, i.landmarks), m = this.zoomSmoother.update(u);
231
+ let p = null;
232
+ if (this.prevZoomDist !== null) {
233
+ const f = m - this.prevZoomDist;
234
+ Math.abs(f) > this.tuning.zoomDeadzoneRatio && (p = f);
235
+ }
236
+ return this.prevZoomDist = m, this.buildOutput(null, p);
237
+ }
238
+ return this.buildOutput(null, null);
239
+ }
240
+ transitionTo(t) {
241
+ 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);
242
+ }
243
+ buildOutput(t, e) {
244
+ return { mode: this.mode, panDelta: t, zoomDelta: e };
245
+ }
246
+ reset() {
247
+ this.transitionTo("idle");
248
+ }
249
+ }
250
+ const O = [
251
+ [0, 1],
252
+ [1, 2],
253
+ [2, 3],
254
+ [3, 4],
255
+ // thumb
256
+ [0, 5],
257
+ [5, 6],
258
+ [6, 7],
259
+ [7, 8],
260
+ // index
261
+ [0, 9],
262
+ [9, 10],
263
+ [10, 11],
264
+ [11, 12],
265
+ // middle
266
+ [0, 13],
267
+ [13, 14],
268
+ [14, 15],
269
+ [15, 16],
270
+ // ring
271
+ [0, 17],
272
+ [17, 18],
273
+ [18, 19],
274
+ [19, 20],
275
+ // pinky
276
+ [5, 9],
277
+ [9, 13],
278
+ [13, 17]
279
+ // palm cross
280
+ ], z = [
281
+ h.THUMB_TIP,
282
+ h.INDEX_TIP,
283
+ h.MIDDLE_TIP,
284
+ h.RING_TIP,
285
+ h.PINKY_TIP
286
+ ];
287
+ class U {
288
+ constructor(t) {
289
+ a(this, "container");
290
+ a(this, "canvas");
291
+ a(this, "ctx");
292
+ a(this, "badge");
293
+ a(this, "config");
294
+ 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);
295
+ const e = this.canvas.getContext("2d");
296
+ if (!e) throw new Error("Cannot get 2D canvas context");
297
+ this.ctx = e;
298
+ }
299
+ /** Attach video element produced by GestureController */
300
+ attachVideo(t) {
301
+ t.className = "ol-gesture-video", t.style.cssText = "position:absolute;top:0;left:0;width:100%;height:100%;object-fit:cover;transform:scaleX(-1);", this.container.insertBefore(t, this.canvas);
302
+ }
303
+ /** Mount the overlay into the given parent (usually document.body or map container) */
304
+ mount(t) {
305
+ t.appendChild(this.container);
306
+ }
307
+ unmount() {
308
+ var t;
309
+ (t = this.container.parentElement) == null || t.removeChild(this.container);
310
+ }
311
+ /** Called each frame with the latest gesture frame and mode. */
312
+ render(t, e) {
313
+ this.updateBadge(e);
314
+ const n = this.config.width, s = this.config.height;
315
+ if (this.canvas.width = n, this.canvas.height = s, this.ctx.clearRect(0, 0, n, s), t !== null)
316
+ for (const l of t.hands)
317
+ this.drawSkeleton(l.landmarks, e, l.gesture === "fist");
318
+ }
319
+ drawSkeleton(t, e, n) {
320
+ const { ctx: s } = this, l = this.config.width, i = this.config.height, d = (r) => (1 - r.x) * l, c = (r) => r.y * i;
321
+ s.strokeStyle = T.connection, s.lineWidth = 1.5;
322
+ for (const [r, u] of O)
323
+ !t[r] || !t[u] || (s.beginPath(), s.moveTo(d(t[r]), c(t[r])), s.lineTo(d(t[u]), c(t[u])), s.stroke());
324
+ for (let r = 0; r < t.length; r++) {
325
+ const u = t[r], m = z.includes(r), p = e !== "idle" && m ? T.fingertipGlow : T.landmark;
326
+ s.beginPath(), s.arc(d(u), c(u), m ? 5 : 3, 0, Math.PI * 2), s.fillStyle = p, s.fill(), e !== "idle" && m && (s.shadowBlur = n ? 12 : 6, s.shadowColor = T.fingertipGlow, s.fill(), s.shadowBlur = 0);
327
+ }
328
+ }
329
+ updateBadge(t) {
330
+ const e = {
331
+ idle: "Idle",
332
+ panning: "Pan",
333
+ zooming: "Zoom"
334
+ };
335
+ this.badge.textContent = e[t], this.badge.className = `ol-gesture-badge ol-gesture-badge--${t}`;
336
+ }
337
+ applyContainerStyles() {
338
+ const { mode: t, position: e, width: n, height: s, opacity: l } = this.config;
339
+ 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(l), this.container.style.display = t === "hidden" ? "none" : "block", t === "corner") {
340
+ this.container.style.width = `${n}px`, this.container.style.height = `${s}px`;
341
+ const i = "16px";
342
+ e === "bottom-right" ? (this.container.style.bottom = i, this.container.style.right = i) : e === "bottom-left" ? (this.container.style.bottom = i, this.container.style.left = i) : e === "top-right" ? (this.container.style.top = i, this.container.style.right = i) : (this.container.style.top = i, this.container.style.left = i);
343
+ } 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");
344
+ }
345
+ updateConfig(t) {
346
+ Object.assign(this.config, t), this.applyContainerStyles();
347
+ }
348
+ }
349
+ export {
350
+ T as COLORS,
351
+ H as DEFAULT_TUNING_CONFIG,
352
+ L as DEFAULT_WEBCAM_CONFIG,
353
+ k as GestureController,
354
+ B as GestureStateMachine,
355
+ h as LANDMARKS,
356
+ U as WebcamOverlay,
357
+ x as classifyGesture,
358
+ N as getHandSize,
359
+ S as getTwoHandDistance
360
+ };
@@ -0,0 +1,47 @@
1
+ export type GestureMode = 'idle' | 'panning' | 'zooming';
2
+ export type HandednessLabel = 'Left' | 'Right';
3
+ export type GestureType = 'openPalm' | 'fist' | 'none';
4
+ export interface Point2D {
5
+ x: number;
6
+ y: number;
7
+ }
8
+ export interface HandLandmark {
9
+ x: number;
10
+ y: number;
11
+ z: number;
12
+ }
13
+ export interface DetectedHand {
14
+ handedness: HandednessLabel;
15
+ score: number;
16
+ landmarks: HandLandmark[];
17
+ gesture: GestureType;
18
+ }
19
+ export interface GestureFrame {
20
+ timestamp: number;
21
+ hands: DetectedHand[];
22
+ leftHand: DetectedHand | null;
23
+ rightHand: DetectedHand | null;
24
+ }
25
+ export interface WebcamConfig {
26
+ enabled: boolean;
27
+ /** 'full' | 'corner' | 'hidden' */
28
+ mode: 'full' | 'corner' | 'hidden';
29
+ opacity: number;
30
+ position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
31
+ width: number;
32
+ height: number;
33
+ }
34
+ export interface TuningConfig {
35
+ actionDwellMs: number;
36
+ releaseGraceMs: number;
37
+ panDeadzonePx: number;
38
+ zoomDeadzoneRatio: number;
39
+ smoothingAlpha: number;
40
+ minDetectionConfidence: number;
41
+ minTrackingConfidence: number;
42
+ minPresenceConfidence: number;
43
+ }
44
+ export interface SmoothedPoint {
45
+ x: number;
46
+ y: number;
47
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@map-gesture-controls/core",
3
+ "version": "0.1.3",
4
+ "description": "Map-agnostic hand gesture detection and state machine (MediaPipe)",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "module": "dist/index.js",
8
+ "types": "dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "import": "./dist/index.js",
12
+ "types": "./dist/index.d.ts"
13
+ },
14
+ "./style.css": "./dist/style.css"
15
+ },
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "scripts": {
20
+ "build:lib": "tsc -p tsconfig.lib.json && vite build --config vite.lib.config.ts",
21
+ "type-check": "tsc --noEmit"
22
+ },
23
+ "dependencies": {
24
+ "@mediapipe/tasks-vision": "^0.10.14"
25
+ },
26
+ "devDependencies": {
27
+ "typescript": "^5.4.5",
28
+ "vite": "^5.2.11"
29
+ },
30
+ "keywords": [
31
+ "mediapipe",
32
+ "hand-gestures",
33
+ "gesture-detection"
34
+ ],
35
+ "overrides": {
36
+ "esbuild": "^0.25.0"
37
+ },
38
+ "license": "MIT"
39
+ }