@map-gesture-controls/google-maps 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -0
- package/dist/GestureMapController.d.ts +56 -0
- package/dist/GestureMapController.js +306 -0
- package/dist/GoogleMapsGestureInteraction.d.ts +71 -0
- package/dist/GoogleMapsGestureInteraction.js +192 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +212 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.js +1 -0
- package/package.json +69 -0
- package/style.css +85 -0
package/README.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# @map-gesture-controls/google-maps
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@map-gesture-controls/google-maps)
|
|
4
|
+
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://bundlephobia.com/package/@map-gesture-controls/google-maps)
|
|
6
|
+
[](https://www.typescriptlang.org/)
|
|
7
|
+
|
|
8
|
+
**Control Google Maps with hand gestures.** No mouse, no touch, no backend. Point your webcam and use a fist or pinch to pan, zoom, and rotate. Powered by [MediaPipe](https://developers.google.com/mediapipe) hand-tracking running entirely in the browser. Your camera feed never leaves the device.
|
|
9
|
+
|
|
10
|
+
## Demo
|
|
11
|
+
|
|
12
|
+
Try it live at **[sanderdesnaijer.github.io/map-gesture-controls](https://sanderdesnaijer.github.io/map-gesture-controls/)**
|
|
13
|
+
|
|
14
|
+
<p align="center">
|
|
15
|
+
<img src="https://raw.githubusercontent.com/sanderdesnaijer/map-gesture-controls/main/docs/public/google-maps-gesture-controls-mediapipe.gif" alt="Screen recording of the Google Maps gesture demo: a Google Maps instance 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" />
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install @map-gesture-controls/google-maps @googlemaps/js-api-loader
|
|
22
|
+
npm install -D @types/google.maps
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick start
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { Loader } from '@googlemaps/js-api-loader';
|
|
29
|
+
import { GestureMapController } from '@map-gesture-controls/google-maps';
|
|
30
|
+
import '@map-gesture-controls/google-maps/style.css';
|
|
31
|
+
|
|
32
|
+
const loader = new Loader({ apiKey: 'YOUR_API_KEY', version: 'weekly' });
|
|
33
|
+
const { Map } = await loader.importLibrary('maps');
|
|
34
|
+
|
|
35
|
+
const map = new Map(document.getElementById('map')!, {
|
|
36
|
+
center: { lat: 0, lng: 0 },
|
|
37
|
+
zoom: 2,
|
|
38
|
+
mapId: 'YOUR_MAP_ID', // enables vector maps (required for rotation)
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const controller = new GestureMapController({ map });
|
|
42
|
+
|
|
43
|
+
// Must be called from a user interaction (e.g. button click) for webcam permission
|
|
44
|
+
await controller.start();
|
|
45
|
+
|
|
46
|
+
// Later, to tear down:
|
|
47
|
+
controller.stop();
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
You need a Google Maps API key and a Map ID from the [Google Cloud Console](https://console.cloud.google.com/google/maps-apis). Create the Map ID with the **Vector** map type to enable rotation support.
|
|
51
|
+
|
|
52
|
+
## How it works
|
|
53
|
+
|
|
54
|
+
1. **Webcam capture** - `GestureController` opens the camera and feeds each frame to MediaPipe Hand Landmarker, returning 21 3D landmarks per hand.
|
|
55
|
+
2. **Gesture classification** - `GestureStateMachine` classifies frames in real time: left fist or pinch = pan, right fist or pinch = zoom (vertical movement), both hands active = rotate, anything else is idle. Dwell timers and grace periods prevent accidental triggers.
|
|
56
|
+
3. **Map integration** - `GoogleMapsGestureInteraction` translates hand movement deltas into Google Maps `moveCamera()` calls for pan, zoom, and heading changes. Pan deltas are rotated by the current heading so gesture direction always matches what you see on screen, even on rotated vector maps.
|
|
57
|
+
|
|
58
|
+
## Gestures
|
|
59
|
+
|
|
60
|
+
Both **fist** and **pinch** (thumb and index finger touching) trigger the same actions, use whichever feels more comfortable.
|
|
61
|
+
|
|
62
|
+
| Gesture | How to perform | Map action |
|
|
63
|
+
| --- | --- | --- |
|
|
64
|
+
| **Pan** | Left fist or pinch, move hand in any direction | Drags the map |
|
|
65
|
+
| **Zoom** | Right fist or pinch, move hand up or down | Zooms in (up) or out (down) |
|
|
66
|
+
| **Rotate** | Both hands fist or pinch, tilt wrists clockwise or counter-clockwise | Rotates the map |
|
|
67
|
+
| **Reset** | Bring both hands together (pray/namaste), hold 1 second | Resets pan, zoom, and rotation to initial state |
|
|
68
|
+
| **Idle** | Any other hand position | Map stays still |
|
|
69
|
+
|
|
70
|
+
## Configuration
|
|
71
|
+
|
|
72
|
+
All options are optional. Defaults work well out of the box.
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
const controller = new GestureMapController({
|
|
76
|
+
map,
|
|
77
|
+
webcam: {
|
|
78
|
+
position: 'top-left', // overlay corner position
|
|
79
|
+
width: 240,
|
|
80
|
+
height: 180,
|
|
81
|
+
opacity: 0.7,
|
|
82
|
+
},
|
|
83
|
+
tuning: {
|
|
84
|
+
actionDwellMs: 40, // ms before confirming a gesture
|
|
85
|
+
releaseGraceMs: 80, // ms grace period after gesture ends
|
|
86
|
+
panDeadzonePx: 5, // react to smaller movements
|
|
87
|
+
smoothingAlpha: 0.4, // smoother but still responsive
|
|
88
|
+
},
|
|
89
|
+
debug: true, // log gesture state to console
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
See the full configuration reference in the [documentation](https://sanderdesnaijer.github.io/map-gesture-controls/).
|
|
94
|
+
|
|
95
|
+
## Exports
|
|
96
|
+
|
|
97
|
+
This package re-exports the entire [`@map-gesture-controls/core`](https://www.npmjs.com/package/@map-gesture-controls/core) API, so you only need one import. On top of core, it adds:
|
|
98
|
+
|
|
99
|
+
| Export | Type | Description |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `GestureMapController` | Class | High-level controller that wires gesture detection to a Google Maps instance |
|
|
102
|
+
| `GoogleMapsGestureInteraction` | Class | Low-level Google Maps interaction for custom setups |
|
|
103
|
+
| `GestureMapControllerConfig` | Type | Configuration interface |
|
|
104
|
+
|
|
105
|
+
## Use cases
|
|
106
|
+
|
|
107
|
+
- **Museum and exhibit kiosks** - visitors explore maps without touching a shared screen
|
|
108
|
+
- **Accessibility** - hands-free map navigation for users with limited mobility
|
|
109
|
+
- **Live presentations** - control a projected map from across the room
|
|
110
|
+
- **Public displays** - touchless interaction in medical, retail, or transit environments
|
|
111
|
+
|
|
112
|
+
## Requirements
|
|
113
|
+
|
|
114
|
+
- Google Maps JavaScript API (via `@googlemaps/js-api-loader` or a script tag)
|
|
115
|
+
- A Map ID with **Vector** map type for rotation support
|
|
116
|
+
- `@types/google.maps` as a peer dependency
|
|
117
|
+
- A modern browser with WebGL, `getUserMedia`, and WASM support
|
|
118
|
+
- Chrome 111+, Edge 111+, Firefox 115+, Safari 17+
|
|
119
|
+
|
|
120
|
+
## Related packages
|
|
121
|
+
|
|
122
|
+
| Package | Description |
|
|
123
|
+
| --- | --- |
|
|
124
|
+
| [`@map-gesture-controls/core`](https://www.npmjs.com/package/@map-gesture-controls/core) | Map-agnostic gesture detection engine (included in this package) |
|
|
125
|
+
| [`@map-gesture-controls/ol`](https://www.npmjs.com/package/@map-gesture-controls/ol) | OpenLayers integration |
|
|
126
|
+
|
|
127
|
+
## Documentation
|
|
128
|
+
|
|
129
|
+
Full docs, live demos, and API reference at **[sanderdesnaijer.github.io/map-gesture-controls](https://sanderdesnaijer.github.io/map-gesture-controls/)**
|
|
130
|
+
|
|
131
|
+
## Privacy
|
|
132
|
+
|
|
133
|
+
All gesture processing runs locally in the browser. No video data is sent to any server. MediaPipe WASM and model files are loaded from public CDNs.
|
|
134
|
+
|
|
135
|
+
Built by [Sander de Snaijer](https://www.sanderdesnaijer.com).
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
MIT
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { GestureMapControllerConfig } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* GestureMapController
|
|
4
|
+
*
|
|
5
|
+
* Top-level public API for Google Maps. Wires together all subsystems:
|
|
6
|
+
* GestureController -> GestureStateMachine -> GoogleMapsGestureInteraction
|
|
7
|
+
* -> WebcamOverlay
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const ctrl = new GestureMapController({ map });
|
|
11
|
+
* await ctrl.start();
|
|
12
|
+
* // ...
|
|
13
|
+
* ctrl.stop();
|
|
14
|
+
*/
|
|
15
|
+
export declare class GestureMapController {
|
|
16
|
+
private config;
|
|
17
|
+
private gestureController;
|
|
18
|
+
private stateMachine;
|
|
19
|
+
private overlay;
|
|
20
|
+
private interaction;
|
|
21
|
+
private lastFrame;
|
|
22
|
+
private rafHandle;
|
|
23
|
+
private started;
|
|
24
|
+
private paused;
|
|
25
|
+
private resetPoseStart;
|
|
26
|
+
private resetPoseTriggered;
|
|
27
|
+
private resetPoseGraceTimer;
|
|
28
|
+
private readonly resetPoseDurationMs;
|
|
29
|
+
private readonly resetPoseGraceMs;
|
|
30
|
+
private readonly initialZoom;
|
|
31
|
+
private readonly initialCenter;
|
|
32
|
+
private readonly initialHeading;
|
|
33
|
+
constructor(userConfig: GestureMapControllerConfig);
|
|
34
|
+
/**
|
|
35
|
+
* Initialise webcam + MediaPipe, mount overlay, begin detection loop.
|
|
36
|
+
* Must be called from a user-gesture event (e.g. button click) to allow
|
|
37
|
+
* webcam permission prompt.
|
|
38
|
+
*/
|
|
39
|
+
start(): Promise<void>;
|
|
40
|
+
/** Stop detection and remove overlay. Safe to call start() again afterwards. */
|
|
41
|
+
stop(): void;
|
|
42
|
+
/** Pause detection (overlay stays visible but inactive). */
|
|
43
|
+
pause(): void;
|
|
44
|
+
/** Resume after pause. */
|
|
45
|
+
resume(): void;
|
|
46
|
+
private renderLoop;
|
|
47
|
+
/**
|
|
48
|
+
* Returns true when both hands are held close together based on wrist proximity.
|
|
49
|
+
* Uses Euclidean distance between the two wrists in normalised screen-space
|
|
50
|
+
* coordinates (0 to 1). Threshold is 0.45.
|
|
51
|
+
*/
|
|
52
|
+
private isPrayPose;
|
|
53
|
+
private handleVisibilityChange;
|
|
54
|
+
private resetTransientState;
|
|
55
|
+
private logDebug;
|
|
56
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { DEFAULT_WEBCAM_CONFIG, DEFAULT_TUNING_CONFIG, LANDMARKS, GestureController, GestureStateMachine, WebcamOverlay, } from '@map-gesture-controls/core';
|
|
2
|
+
import { GoogleMapsGestureInteraction } from './GoogleMapsGestureInteraction.js';
|
|
3
|
+
/**
|
|
4
|
+
* GestureMapController
|
|
5
|
+
*
|
|
6
|
+
* Top-level public API for Google Maps. Wires together all subsystems:
|
|
7
|
+
* GestureController -> GestureStateMachine -> GoogleMapsGestureInteraction
|
|
8
|
+
* -> WebcamOverlay
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const ctrl = new GestureMapController({ map });
|
|
12
|
+
* await ctrl.start();
|
|
13
|
+
* // ...
|
|
14
|
+
* ctrl.stop();
|
|
15
|
+
*/
|
|
16
|
+
export class GestureMapController {
|
|
17
|
+
constructor(userConfig) {
|
|
18
|
+
Object.defineProperty(this, "config", {
|
|
19
|
+
enumerable: true,
|
|
20
|
+
configurable: true,
|
|
21
|
+
writable: true,
|
|
22
|
+
value: void 0
|
|
23
|
+
});
|
|
24
|
+
Object.defineProperty(this, "gestureController", {
|
|
25
|
+
enumerable: true,
|
|
26
|
+
configurable: true,
|
|
27
|
+
writable: true,
|
|
28
|
+
value: void 0
|
|
29
|
+
});
|
|
30
|
+
Object.defineProperty(this, "stateMachine", {
|
|
31
|
+
enumerable: true,
|
|
32
|
+
configurable: true,
|
|
33
|
+
writable: true,
|
|
34
|
+
value: void 0
|
|
35
|
+
});
|
|
36
|
+
Object.defineProperty(this, "overlay", {
|
|
37
|
+
enumerable: true,
|
|
38
|
+
configurable: true,
|
|
39
|
+
writable: true,
|
|
40
|
+
value: void 0
|
|
41
|
+
});
|
|
42
|
+
Object.defineProperty(this, "interaction", {
|
|
43
|
+
enumerable: true,
|
|
44
|
+
configurable: true,
|
|
45
|
+
writable: true,
|
|
46
|
+
value: void 0
|
|
47
|
+
});
|
|
48
|
+
Object.defineProperty(this, "lastFrame", {
|
|
49
|
+
enumerable: true,
|
|
50
|
+
configurable: true,
|
|
51
|
+
writable: true,
|
|
52
|
+
value: null
|
|
53
|
+
});
|
|
54
|
+
Object.defineProperty(this, "rafHandle", {
|
|
55
|
+
enumerable: true,
|
|
56
|
+
configurable: true,
|
|
57
|
+
writable: true,
|
|
58
|
+
value: null
|
|
59
|
+
});
|
|
60
|
+
Object.defineProperty(this, "started", {
|
|
61
|
+
enumerable: true,
|
|
62
|
+
configurable: true,
|
|
63
|
+
writable: true,
|
|
64
|
+
value: false
|
|
65
|
+
});
|
|
66
|
+
Object.defineProperty(this, "paused", {
|
|
67
|
+
enumerable: true,
|
|
68
|
+
configurable: true,
|
|
69
|
+
writable: true,
|
|
70
|
+
value: false
|
|
71
|
+
});
|
|
72
|
+
Object.defineProperty(this, "resetPoseStart", {
|
|
73
|
+
enumerable: true,
|
|
74
|
+
configurable: true,
|
|
75
|
+
writable: true,
|
|
76
|
+
value: null
|
|
77
|
+
});
|
|
78
|
+
Object.defineProperty(this, "resetPoseTriggered", {
|
|
79
|
+
enumerable: true,
|
|
80
|
+
configurable: true,
|
|
81
|
+
writable: true,
|
|
82
|
+
value: false
|
|
83
|
+
});
|
|
84
|
+
Object.defineProperty(this, "resetPoseGraceTimer", {
|
|
85
|
+
enumerable: true,
|
|
86
|
+
configurable: true,
|
|
87
|
+
writable: true,
|
|
88
|
+
value: null
|
|
89
|
+
});
|
|
90
|
+
Object.defineProperty(this, "resetPoseDurationMs", {
|
|
91
|
+
enumerable: true,
|
|
92
|
+
configurable: true,
|
|
93
|
+
writable: true,
|
|
94
|
+
value: 1000
|
|
95
|
+
});
|
|
96
|
+
Object.defineProperty(this, "resetPoseGraceMs", {
|
|
97
|
+
enumerable: true,
|
|
98
|
+
configurable: true,
|
|
99
|
+
writable: true,
|
|
100
|
+
value: 300
|
|
101
|
+
});
|
|
102
|
+
Object.defineProperty(this, "initialZoom", {
|
|
103
|
+
enumerable: true,
|
|
104
|
+
configurable: true,
|
|
105
|
+
writable: true,
|
|
106
|
+
value: void 0
|
|
107
|
+
});
|
|
108
|
+
Object.defineProperty(this, "initialCenter", {
|
|
109
|
+
enumerable: true,
|
|
110
|
+
configurable: true,
|
|
111
|
+
writable: true,
|
|
112
|
+
value: void 0
|
|
113
|
+
});
|
|
114
|
+
Object.defineProperty(this, "initialHeading", {
|
|
115
|
+
enumerable: true,
|
|
116
|
+
configurable: true,
|
|
117
|
+
writable: true,
|
|
118
|
+
value: void 0
|
|
119
|
+
});
|
|
120
|
+
Object.defineProperty(this, "handleVisibilityChange", {
|
|
121
|
+
enumerable: true,
|
|
122
|
+
configurable: true,
|
|
123
|
+
writable: true,
|
|
124
|
+
value: () => {
|
|
125
|
+
if (document.hidden) {
|
|
126
|
+
this.pause();
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
this.resume();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
const webcamConfig = { ...DEFAULT_WEBCAM_CONFIG, ...userConfig.webcam };
|
|
134
|
+
const tuningConfig = { ...DEFAULT_TUNING_CONFIG, ...userConfig.tuning };
|
|
135
|
+
this.config = {
|
|
136
|
+
map: userConfig.map,
|
|
137
|
+
webcam: webcamConfig,
|
|
138
|
+
tuning: tuningConfig,
|
|
139
|
+
debug: userConfig.debug ?? false,
|
|
140
|
+
};
|
|
141
|
+
this.initialZoom = userConfig.map.getZoom() ?? 10;
|
|
142
|
+
this.initialCenter = userConfig.map.getCenter() ?? null;
|
|
143
|
+
this.initialHeading = userConfig.map.getHeading() ?? 0;
|
|
144
|
+
this.gestureController = new GestureController(tuningConfig, (frame) => {
|
|
145
|
+
this.lastFrame = frame;
|
|
146
|
+
});
|
|
147
|
+
this.stateMachine = new GestureStateMachine(tuningConfig);
|
|
148
|
+
this.overlay = new WebcamOverlay(webcamConfig);
|
|
149
|
+
this.interaction = new GoogleMapsGestureInteraction(userConfig.map);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Initialise webcam + MediaPipe, mount overlay, begin detection loop.
|
|
153
|
+
* Must be called from a user-gesture event (e.g. button click) to allow
|
|
154
|
+
* webcam permission prompt.
|
|
155
|
+
*/
|
|
156
|
+
async start() {
|
|
157
|
+
if (this.started)
|
|
158
|
+
return;
|
|
159
|
+
try {
|
|
160
|
+
const videoEl = await this.gestureController.init();
|
|
161
|
+
this.overlay.attachVideo(videoEl);
|
|
162
|
+
const mapDiv = this.config.map.getDiv();
|
|
163
|
+
this.overlay.mount(mapDiv ?? document.body);
|
|
164
|
+
this.resetTransientState();
|
|
165
|
+
this.started = true;
|
|
166
|
+
this.paused = false;
|
|
167
|
+
this.interaction.syncFromMap();
|
|
168
|
+
this.gestureController.start();
|
|
169
|
+
this.renderLoop();
|
|
170
|
+
// Pause when tab is hidden to save resources
|
|
171
|
+
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
this.overlay.unmount();
|
|
175
|
+
this.gestureController.destroy();
|
|
176
|
+
this.resetTransientState();
|
|
177
|
+
this.started = false;
|
|
178
|
+
this.paused = false;
|
|
179
|
+
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Stop detection and remove overlay. Safe to call start() again afterwards. */
|
|
184
|
+
stop() {
|
|
185
|
+
this.gestureController.destroy();
|
|
186
|
+
this.overlay.unmount();
|
|
187
|
+
this.stateMachine.reset();
|
|
188
|
+
this.resetTransientState();
|
|
189
|
+
this.interaction.dispose();
|
|
190
|
+
// Recreate the interaction so a subsequent start() has fresh listeners.
|
|
191
|
+
this.interaction = new GoogleMapsGestureInteraction(this.config.map);
|
|
192
|
+
if (this.rafHandle !== null) {
|
|
193
|
+
cancelAnimationFrame(this.rafHandle);
|
|
194
|
+
this.rafHandle = null;
|
|
195
|
+
}
|
|
196
|
+
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
|
|
197
|
+
this.started = false;
|
|
198
|
+
this.paused = false;
|
|
199
|
+
}
|
|
200
|
+
/** Pause detection (overlay stays visible but inactive). */
|
|
201
|
+
pause() {
|
|
202
|
+
this.paused = true;
|
|
203
|
+
this.gestureController.stop();
|
|
204
|
+
this.stateMachine.reset();
|
|
205
|
+
}
|
|
206
|
+
/** Resume after pause. */
|
|
207
|
+
resume() {
|
|
208
|
+
if (!this.paused)
|
|
209
|
+
return;
|
|
210
|
+
this.paused = false;
|
|
211
|
+
this.interaction.syncFromMap();
|
|
212
|
+
this.gestureController.start();
|
|
213
|
+
}
|
|
214
|
+
renderLoop() {
|
|
215
|
+
this.rafHandle = requestAnimationFrame(() => this.renderLoop());
|
|
216
|
+
if (this.paused) {
|
|
217
|
+
this.overlay.render(null, 'idle');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const frame = this.lastFrame;
|
|
221
|
+
if (frame === null) {
|
|
222
|
+
this.overlay.render(null, 'idle');
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const output = this.stateMachine.update(frame);
|
|
226
|
+
this.interaction.apply(output);
|
|
227
|
+
let resetProgress = 0;
|
|
228
|
+
const { leftHand, rightHand, timestamp } = frame;
|
|
229
|
+
const resetPoseActive = !!leftHand &&
|
|
230
|
+
!!rightHand &&
|
|
231
|
+
leftHand.gesture !== 'fist' &&
|
|
232
|
+
leftHand.gesture !== 'pinch' &&
|
|
233
|
+
rightHand.gesture !== 'fist' &&
|
|
234
|
+
rightHand.gesture !== 'pinch' &&
|
|
235
|
+
this.isPrayPose(leftHand.landmarks, rightHand.landmarks);
|
|
236
|
+
if (resetPoseActive) {
|
|
237
|
+
// Pose is active: clear any grace timer and start/continue dwell
|
|
238
|
+
this.resetPoseGraceTimer = null;
|
|
239
|
+
if (this.resetPoseStart === null) {
|
|
240
|
+
this.resetPoseStart = timestamp;
|
|
241
|
+
this.resetPoseTriggered = false;
|
|
242
|
+
}
|
|
243
|
+
const elapsed = timestamp - this.resetPoseStart;
|
|
244
|
+
resetProgress = Math.min(1, elapsed / this.resetPoseDurationMs);
|
|
245
|
+
if (!this.resetPoseTriggered && resetProgress >= 1) {
|
|
246
|
+
this.resetPoseTriggered = true;
|
|
247
|
+
this.config.map.moveCamera({
|
|
248
|
+
zoom: this.initialZoom,
|
|
249
|
+
center: this.initialCenter ?? undefined,
|
|
250
|
+
heading: this.initialHeading,
|
|
251
|
+
});
|
|
252
|
+
this.interaction.syncFromMap();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
else if (this.resetPoseStart !== null) {
|
|
256
|
+
// Pose dropped -- start grace period before resetting the timer
|
|
257
|
+
if (this.resetPoseGraceTimer === null) {
|
|
258
|
+
this.resetPoseGraceTimer = timestamp;
|
|
259
|
+
}
|
|
260
|
+
else if (timestamp - this.resetPoseGraceTimer >= this.resetPoseGraceMs) {
|
|
261
|
+
this.resetPoseStart = null;
|
|
262
|
+
this.resetPoseTriggered = false;
|
|
263
|
+
this.resetPoseGraceTimer = null;
|
|
264
|
+
}
|
|
265
|
+
// While in grace period, keep showing the last progress value
|
|
266
|
+
if (this.resetPoseStart !== null) {
|
|
267
|
+
const elapsed = timestamp - this.resetPoseStart;
|
|
268
|
+
resetProgress = Math.min(1, elapsed / this.resetPoseDurationMs);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
this.resetPoseGraceTimer = null;
|
|
273
|
+
}
|
|
274
|
+
this.overlay.render(frame, output.mode, resetProgress);
|
|
275
|
+
if (this.config.debug) {
|
|
276
|
+
this.logDebug(output.mode, frame);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Returns true when both hands are held close together based on wrist proximity.
|
|
281
|
+
* Uses Euclidean distance between the two wrists in normalised screen-space
|
|
282
|
+
* coordinates (0 to 1). Threshold is 0.45.
|
|
283
|
+
*/
|
|
284
|
+
isPrayPose(left, right) {
|
|
285
|
+
const lWrist = left[LANDMARKS.WRIST];
|
|
286
|
+
const rWrist = right[LANDMARKS.WRIST];
|
|
287
|
+
if (!lWrist || !rWrist)
|
|
288
|
+
return false;
|
|
289
|
+
// Hands are together when wrists are close in both X and Y
|
|
290
|
+
const dx = lWrist.x - rWrist.x;
|
|
291
|
+
const dy = lWrist.y - rWrist.y;
|
|
292
|
+
return Math.sqrt(dx * dx + dy * dy) < 0.45;
|
|
293
|
+
}
|
|
294
|
+
resetTransientState() {
|
|
295
|
+
this.lastFrame = null;
|
|
296
|
+
this.resetPoseStart = null;
|
|
297
|
+
this.resetPoseTriggered = false;
|
|
298
|
+
this.resetPoseGraceTimer = null;
|
|
299
|
+
}
|
|
300
|
+
logDebug(mode, frame) {
|
|
301
|
+
const hands = frame.hands
|
|
302
|
+
.map((h) => `${h.handedness}:${h.gesture}(${h.score.toFixed(2)})`)
|
|
303
|
+
.join(' ');
|
|
304
|
+
console.debug(`[gmaps-gestures] mode=${mode} ${hands}`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { StateMachineOutput } from '@map-gesture-controls/core';
|
|
2
|
+
/**
|
|
3
|
+
* GoogleMapsGestureInteraction
|
|
4
|
+
*
|
|
5
|
+
* Translates GestureStateMachine output into Google Maps API calls.
|
|
6
|
+
* Uses moveCamera() for all operations to avoid Google Maps' built-in
|
|
7
|
+
* smooth animations, which cause lag when applied every frame.
|
|
8
|
+
*
|
|
9
|
+
* Tracks zoom and heading internally so that small per-frame deltas
|
|
10
|
+
* accumulate correctly. Google Maps quantizes values returned by
|
|
11
|
+
* getZoom()/getHeading(), which would swallow fractional increments
|
|
12
|
+
* if we read them back each frame.
|
|
13
|
+
*
|
|
14
|
+
* Listens for zoom_changed / heading_changed so that external changes
|
|
15
|
+
* (built-in controls, wheel zoom, other app code) are picked up
|
|
16
|
+
* before the next gesture delta is applied.
|
|
17
|
+
*
|
|
18
|
+
* Pan: left fist wrist delta -> pixel offset converted to lat/lng delta,
|
|
19
|
+
* rotated by current heading so screen direction always matches.
|
|
20
|
+
* Webcam is mirrored so dx is negated.
|
|
21
|
+
* Zoom: right fist wrist vertical delta -> zoom level change (up = in, down = out).
|
|
22
|
+
*/
|
|
23
|
+
export declare class GoogleMapsGestureInteraction {
|
|
24
|
+
private map;
|
|
25
|
+
private panScale;
|
|
26
|
+
private zoomScale;
|
|
27
|
+
private currentZoom;
|
|
28
|
+
private currentHeading;
|
|
29
|
+
private applyingChange;
|
|
30
|
+
private zoomListener;
|
|
31
|
+
private headingListener;
|
|
32
|
+
constructor(map: google.maps.Map);
|
|
33
|
+
/**
|
|
34
|
+
* Apply a state machine output frame to the map.
|
|
35
|
+
* Safe to call every animation frame.
|
|
36
|
+
*/
|
|
37
|
+
apply(output: StateMachineOutput): void;
|
|
38
|
+
/**
|
|
39
|
+
* Pan map by a normalised delta (0-1 range from webcam space).
|
|
40
|
+
* dx/dy are hand movement as a fraction of frame width/height.
|
|
41
|
+
*
|
|
42
|
+
* Converts pixel offsets to lat/lng deltas using the current zoom level,
|
|
43
|
+
* then rotates by the current heading so that pan direction always
|
|
44
|
+
* matches what the user sees on screen (even on rotated vector maps).
|
|
45
|
+
* Applies via moveCamera() for instant, animation-free updates.
|
|
46
|
+
* Webcam is mirrored so dx is negated.
|
|
47
|
+
*/
|
|
48
|
+
private pan;
|
|
49
|
+
/**
|
|
50
|
+
* Rotate map heading. delta in radians: positive = clockwise.
|
|
51
|
+
* Google Maps uses heading in degrees (0-360).
|
|
52
|
+
*/
|
|
53
|
+
private rotate;
|
|
54
|
+
/**
|
|
55
|
+
* Sync internal state with the map's current values.
|
|
56
|
+
* Call after external changes (e.g. reset pose) so that
|
|
57
|
+
* subsequent gesture deltas start from the correct baseline.
|
|
58
|
+
*/
|
|
59
|
+
syncFromMap(): void;
|
|
60
|
+
/**
|
|
61
|
+
* Remove map event listeners. Call when disposing the interaction
|
|
62
|
+
* to avoid leaking listeners on the map instance.
|
|
63
|
+
*/
|
|
64
|
+
dispose(): void;
|
|
65
|
+
/**
|
|
66
|
+
* Zoom map. delta > 0 = zoom in, delta < 0 = zoom out.
|
|
67
|
+
* Tracks zoom internally so fractional per-frame deltas accumulate
|
|
68
|
+
* instead of being lost to Google Maps' integer-snap on getZoom().
|
|
69
|
+
*/
|
|
70
|
+
private zoom;
|
|
71
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GoogleMapsGestureInteraction
|
|
3
|
+
*
|
|
4
|
+
* Translates GestureStateMachine output into Google Maps API calls.
|
|
5
|
+
* Uses moveCamera() for all operations to avoid Google Maps' built-in
|
|
6
|
+
* smooth animations, which cause lag when applied every frame.
|
|
7
|
+
*
|
|
8
|
+
* Tracks zoom and heading internally so that small per-frame deltas
|
|
9
|
+
* accumulate correctly. Google Maps quantizes values returned by
|
|
10
|
+
* getZoom()/getHeading(), which would swallow fractional increments
|
|
11
|
+
* if we read them back each frame.
|
|
12
|
+
*
|
|
13
|
+
* Listens for zoom_changed / heading_changed so that external changes
|
|
14
|
+
* (built-in controls, wheel zoom, other app code) are picked up
|
|
15
|
+
* before the next gesture delta is applied.
|
|
16
|
+
*
|
|
17
|
+
* Pan: left fist wrist delta -> pixel offset converted to lat/lng delta,
|
|
18
|
+
* rotated by current heading so screen direction always matches.
|
|
19
|
+
* Webcam is mirrored so dx is negated.
|
|
20
|
+
* Zoom: right fist wrist vertical delta -> zoom level change (up = in, down = out).
|
|
21
|
+
*/
|
|
22
|
+
export class GoogleMapsGestureInteraction {
|
|
23
|
+
constructor(map) {
|
|
24
|
+
Object.defineProperty(this, "map", {
|
|
25
|
+
enumerable: true,
|
|
26
|
+
configurable: true,
|
|
27
|
+
writable: true,
|
|
28
|
+
value: void 0
|
|
29
|
+
});
|
|
30
|
+
Object.defineProperty(this, "panScale", {
|
|
31
|
+
enumerable: true,
|
|
32
|
+
configurable: true,
|
|
33
|
+
writable: true,
|
|
34
|
+
value: 2.0
|
|
35
|
+
});
|
|
36
|
+
// Wrist vertical delta is ~0.005-0.02 per frame at natural speed.
|
|
37
|
+
// zoomScale=15 ~ 1 zoom level/sec at 30fps with moderate hand movement.
|
|
38
|
+
Object.defineProperty(this, "zoomScale", {
|
|
39
|
+
enumerable: true,
|
|
40
|
+
configurable: true,
|
|
41
|
+
writable: true,
|
|
42
|
+
value: 15.0
|
|
43
|
+
});
|
|
44
|
+
// Internal state to avoid Google Maps quantization eating small deltas.
|
|
45
|
+
Object.defineProperty(this, "currentZoom", {
|
|
46
|
+
enumerable: true,
|
|
47
|
+
configurable: true,
|
|
48
|
+
writable: true,
|
|
49
|
+
value: void 0
|
|
50
|
+
});
|
|
51
|
+
Object.defineProperty(this, "currentHeading", {
|
|
52
|
+
enumerable: true,
|
|
53
|
+
configurable: true,
|
|
54
|
+
writable: true,
|
|
55
|
+
value: void 0
|
|
56
|
+
});
|
|
57
|
+
// Track whether we caused the last change so we can skip our own events.
|
|
58
|
+
Object.defineProperty(this, "applyingChange", {
|
|
59
|
+
enumerable: true,
|
|
60
|
+
configurable: true,
|
|
61
|
+
writable: true,
|
|
62
|
+
value: false
|
|
63
|
+
});
|
|
64
|
+
// Store listener handles so we can clean up.
|
|
65
|
+
Object.defineProperty(this, "zoomListener", {
|
|
66
|
+
enumerable: true,
|
|
67
|
+
configurable: true,
|
|
68
|
+
writable: true,
|
|
69
|
+
value: null
|
|
70
|
+
});
|
|
71
|
+
Object.defineProperty(this, "headingListener", {
|
|
72
|
+
enumerable: true,
|
|
73
|
+
configurable: true,
|
|
74
|
+
writable: true,
|
|
75
|
+
value: null
|
|
76
|
+
});
|
|
77
|
+
this.map = map;
|
|
78
|
+
this.currentZoom = map.getZoom() ?? 10;
|
|
79
|
+
this.currentHeading = map.getHeading() ?? 0;
|
|
80
|
+
this.zoomListener = map.addListener('zoom_changed', () => {
|
|
81
|
+
if (!this.applyingChange) {
|
|
82
|
+
this.currentZoom = map.getZoom() ?? this.currentZoom;
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
this.headingListener = map.addListener('heading_changed', () => {
|
|
86
|
+
if (!this.applyingChange) {
|
|
87
|
+
this.currentHeading = map.getHeading() ?? this.currentHeading;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Apply a state machine output frame to the map.
|
|
93
|
+
* Safe to call every animation frame.
|
|
94
|
+
*/
|
|
95
|
+
apply(output) {
|
|
96
|
+
if (output.panDelta) {
|
|
97
|
+
this.pan(output.panDelta.x, output.panDelta.y);
|
|
98
|
+
}
|
|
99
|
+
if (output.zoomDelta !== null) {
|
|
100
|
+
this.zoom(output.zoomDelta);
|
|
101
|
+
}
|
|
102
|
+
if (output.rotateDelta !== null) {
|
|
103
|
+
this.rotate(output.rotateDelta);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Pan map by a normalised delta (0-1 range from webcam space).
|
|
108
|
+
* dx/dy are hand movement as a fraction of frame width/height.
|
|
109
|
+
*
|
|
110
|
+
* Converts pixel offsets to lat/lng deltas using the current zoom level,
|
|
111
|
+
* then rotates by the current heading so that pan direction always
|
|
112
|
+
* matches what the user sees on screen (even on rotated vector maps).
|
|
113
|
+
* Applies via moveCamera() for instant, animation-free updates.
|
|
114
|
+
* Webcam is mirrored so dx is negated.
|
|
115
|
+
*/
|
|
116
|
+
pan(dx, dy) {
|
|
117
|
+
const center = this.map.getCenter();
|
|
118
|
+
if (!center)
|
|
119
|
+
return;
|
|
120
|
+
const div = this.map.getDiv();
|
|
121
|
+
const mapW = div.clientWidth;
|
|
122
|
+
const mapH = div.clientHeight;
|
|
123
|
+
// Convert normalised webcam delta to screen pixels.
|
|
124
|
+
// Negate dx because webcam is mirrored.
|
|
125
|
+
const screenDx = -dx * mapW * this.panScale;
|
|
126
|
+
const screenDy = dy * mapH * this.panScale;
|
|
127
|
+
// Rotate pixel deltas by heading so pan matches screen direction
|
|
128
|
+
// on rotated maps. Heading is in degrees, clockwise from north.
|
|
129
|
+
const headingRad = (this.currentHeading * Math.PI) / 180;
|
|
130
|
+
const cosH = Math.cos(headingRad);
|
|
131
|
+
const sinH = Math.sin(headingRad);
|
|
132
|
+
const pixelDx = screenDx * cosH - screenDy * sinH;
|
|
133
|
+
const pixelDy = screenDx * sinH + screenDy * cosH;
|
|
134
|
+
// Convert pixel offset to lat/lng offset.
|
|
135
|
+
// At zoom level z, each pixel covers 360 / (256 * 2^z) degrees of longitude.
|
|
136
|
+
const degreesPerPixel = 360 / (256 * Math.pow(2, this.currentZoom));
|
|
137
|
+
const dLng = pixelDx * degreesPerPixel;
|
|
138
|
+
// Latitude degrees per pixel varies by latitude (Mercator).
|
|
139
|
+
// Multiply by cos(lat) to shrink the delta at higher latitudes.
|
|
140
|
+
const latRad = (center.lat() * Math.PI) / 180;
|
|
141
|
+
const dLat = -pixelDy * degreesPerPixel * Math.cos(latRad);
|
|
142
|
+
this.map.moveCamera({
|
|
143
|
+
center: { lat: center.lat() + dLat, lng: center.lng() + dLng },
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Rotate map heading. delta in radians: positive = clockwise.
|
|
148
|
+
* Google Maps uses heading in degrees (0-360).
|
|
149
|
+
*/
|
|
150
|
+
rotate(delta) {
|
|
151
|
+
this.currentHeading = ((this.currentHeading + (delta * 180) / Math.PI) % 360 + 360) % 360;
|
|
152
|
+
this.applyingChange = true;
|
|
153
|
+
this.map.moveCamera({
|
|
154
|
+
heading: this.currentHeading,
|
|
155
|
+
});
|
|
156
|
+
this.applyingChange = false;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Sync internal state with the map's current values.
|
|
160
|
+
* Call after external changes (e.g. reset pose) so that
|
|
161
|
+
* subsequent gesture deltas start from the correct baseline.
|
|
162
|
+
*/
|
|
163
|
+
syncFromMap() {
|
|
164
|
+
this.currentZoom = this.map.getZoom() ?? this.currentZoom;
|
|
165
|
+
this.currentHeading = this.map.getHeading() ?? this.currentHeading;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Remove map event listeners. Call when disposing the interaction
|
|
169
|
+
* to avoid leaking listeners on the map instance.
|
|
170
|
+
*/
|
|
171
|
+
dispose() {
|
|
172
|
+
this.zoomListener?.remove();
|
|
173
|
+
this.headingListener?.remove();
|
|
174
|
+
this.zoomListener = null;
|
|
175
|
+
this.headingListener = null;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Zoom map. delta > 0 = zoom in, delta < 0 = zoom out.
|
|
179
|
+
* Tracks zoom internally so fractional per-frame deltas accumulate
|
|
180
|
+
* instead of being lost to Google Maps' integer-snap on getZoom().
|
|
181
|
+
*/
|
|
182
|
+
zoom(delta) {
|
|
183
|
+
this.currentZoom += delta * this.zoomScale;
|
|
184
|
+
// Clamp to Google Maps' valid range (0-22)
|
|
185
|
+
this.currentZoom = Math.max(0, Math.min(22, this.currentZoom));
|
|
186
|
+
this.applyingChange = true;
|
|
187
|
+
this.map.moveCamera({
|
|
188
|
+
zoom: this.currentZoom,
|
|
189
|
+
});
|
|
190
|
+
this.applyingChange = false;
|
|
191
|
+
}
|
|
192
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { GestureMapController } from './GestureMapController.js';
|
|
2
|
+
export { GoogleMapsGestureInteraction } from './GoogleMapsGestureInteraction.js';
|
|
3
|
+
export type { GestureMapControllerConfig } from './types.js';
|
|
4
|
+
export { GestureController, GestureStateMachine, WebcamOverlay, classifyGesture, createHandClassifier, getHandSize, getTwoHandDistance, DEFAULT_WEBCAM_CONFIG, DEFAULT_TUNING_CONFIG, LANDMARKS, COLORS, } from '@map-gesture-controls/core';
|
|
5
|
+
export type { GestureMode, GestureType, HandednessLabel, GestureFrame, DetectedHand, WebcamConfig, TuningConfig, StateMachineOutput, SmoothedPoint, Point2D, HandLandmark, } from '@map-gesture-controls/core';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
var v = Object.defineProperty;
|
|
2
|
+
var H = (o, e, s) => e in o ? v(o, e, { enumerable: !0, configurable: !0, writable: !0, value: s }) : o[e] = s;
|
|
3
|
+
var t = (o, e, s) => H(o, typeof e != "symbol" ? e + "" : e, s);
|
|
4
|
+
import { DEFAULT_WEBCAM_CONFIG as T, DEFAULT_TUNING_CONFIG as D, GestureController as S, GestureStateMachine as L, WebcamOverlay as G, LANDMARKS as u } from "@map-gesture-controls/core";
|
|
5
|
+
import { COLORS as x, DEFAULT_TUNING_CONFIG as w, DEFAULT_WEBCAM_CONFIG as I, GestureController as E, GestureStateMachine as N, LANDMARKS as W, WebcamOverlay as _, classifyGesture as O, createHandClassifier as R, getHandSize as U, getTwoHandDistance as V } from "@map-gesture-controls/core";
|
|
6
|
+
class p {
|
|
7
|
+
constructor(e) {
|
|
8
|
+
t(this, "map");
|
|
9
|
+
t(this, "panScale", 2);
|
|
10
|
+
// Wrist vertical delta is ~0.005-0.02 per frame at natural speed.
|
|
11
|
+
// zoomScale=15 ~ 1 zoom level/sec at 30fps with moderate hand movement.
|
|
12
|
+
t(this, "zoomScale", 15);
|
|
13
|
+
// Internal state to avoid Google Maps quantization eating small deltas.
|
|
14
|
+
t(this, "currentZoom");
|
|
15
|
+
t(this, "currentHeading");
|
|
16
|
+
// Track whether we caused the last change so we can skip our own events.
|
|
17
|
+
t(this, "applyingChange", !1);
|
|
18
|
+
// Store listener handles so we can clean up.
|
|
19
|
+
t(this, "zoomListener", null);
|
|
20
|
+
t(this, "headingListener", null);
|
|
21
|
+
this.map = e, this.currentZoom = e.getZoom() ?? 10, this.currentHeading = e.getHeading() ?? 0, this.zoomListener = e.addListener("zoom_changed", () => {
|
|
22
|
+
this.applyingChange || (this.currentZoom = e.getZoom() ?? this.currentZoom);
|
|
23
|
+
}), this.headingListener = e.addListener("heading_changed", () => {
|
|
24
|
+
this.applyingChange || (this.currentHeading = e.getHeading() ?? this.currentHeading);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Apply a state machine output frame to the map.
|
|
29
|
+
* Safe to call every animation frame.
|
|
30
|
+
*/
|
|
31
|
+
apply(e) {
|
|
32
|
+
e.panDelta && this.pan(e.panDelta.x, e.panDelta.y), e.zoomDelta !== null && this.zoom(e.zoomDelta), e.rotateDelta !== null && this.rotate(e.rotateDelta);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Pan map by a normalised delta (0-1 range from webcam space).
|
|
36
|
+
* dx/dy are hand movement as a fraction of frame width/height.
|
|
37
|
+
*
|
|
38
|
+
* Converts pixel offsets to lat/lng deltas using the current zoom level,
|
|
39
|
+
* then rotates by the current heading so that pan direction always
|
|
40
|
+
* matches what the user sees on screen (even on rotated vector maps).
|
|
41
|
+
* Applies via moveCamera() for instant, animation-free updates.
|
|
42
|
+
* Webcam is mirrored so dx is negated.
|
|
43
|
+
*/
|
|
44
|
+
pan(e, s) {
|
|
45
|
+
const i = this.map.getCenter();
|
|
46
|
+
if (!i) return;
|
|
47
|
+
const r = this.map.getDiv(), a = r.clientWidth, n = r.clientHeight, l = -e * a * this.panScale, h = s * n * this.panScale, c = this.currentHeading * Math.PI / 180, d = Math.cos(c), m = Math.sin(c), f = l * d - h * m, y = l * m + h * d, g = 360 / (256 * Math.pow(2, this.currentZoom)), P = f * g, C = i.lat() * Math.PI / 180, M = -y * g * Math.cos(C);
|
|
48
|
+
this.map.moveCamera({
|
|
49
|
+
center: { lat: i.lat() + M, lng: i.lng() + P }
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Rotate map heading. delta in radians: positive = clockwise.
|
|
54
|
+
* Google Maps uses heading in degrees (0-360).
|
|
55
|
+
*/
|
|
56
|
+
rotate(e) {
|
|
57
|
+
this.currentHeading = ((this.currentHeading + e * 180 / Math.PI) % 360 + 360) % 360, this.applyingChange = !0, this.map.moveCamera({
|
|
58
|
+
heading: this.currentHeading
|
|
59
|
+
}), this.applyingChange = !1;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Sync internal state with the map's current values.
|
|
63
|
+
* Call after external changes (e.g. reset pose) so that
|
|
64
|
+
* subsequent gesture deltas start from the correct baseline.
|
|
65
|
+
*/
|
|
66
|
+
syncFromMap() {
|
|
67
|
+
this.currentZoom = this.map.getZoom() ?? this.currentZoom, this.currentHeading = this.map.getHeading() ?? this.currentHeading;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Remove map event listeners. Call when disposing the interaction
|
|
71
|
+
* to avoid leaking listeners on the map instance.
|
|
72
|
+
*/
|
|
73
|
+
dispose() {
|
|
74
|
+
var e, s;
|
|
75
|
+
(e = this.zoomListener) == null || e.remove(), (s = this.headingListener) == null || s.remove(), this.zoomListener = null, this.headingListener = null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Zoom map. delta > 0 = zoom in, delta < 0 = zoom out.
|
|
79
|
+
* Tracks zoom internally so fractional per-frame deltas accumulate
|
|
80
|
+
* instead of being lost to Google Maps' integer-snap on getZoom().
|
|
81
|
+
*/
|
|
82
|
+
zoom(e) {
|
|
83
|
+
this.currentZoom += e * this.zoomScale, this.currentZoom = Math.max(0, Math.min(22, this.currentZoom)), this.applyingChange = !0, this.map.moveCamera({
|
|
84
|
+
zoom: this.currentZoom
|
|
85
|
+
}), this.applyingChange = !1;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
class Z {
|
|
89
|
+
constructor(e) {
|
|
90
|
+
t(this, "config");
|
|
91
|
+
t(this, "gestureController");
|
|
92
|
+
t(this, "stateMachine");
|
|
93
|
+
t(this, "overlay");
|
|
94
|
+
t(this, "interaction");
|
|
95
|
+
t(this, "lastFrame", null);
|
|
96
|
+
t(this, "rafHandle", null);
|
|
97
|
+
t(this, "started", !1);
|
|
98
|
+
t(this, "paused", !1);
|
|
99
|
+
t(this, "resetPoseStart", null);
|
|
100
|
+
t(this, "resetPoseTriggered", !1);
|
|
101
|
+
t(this, "resetPoseGraceTimer", null);
|
|
102
|
+
t(this, "resetPoseDurationMs", 1e3);
|
|
103
|
+
t(this, "resetPoseGraceMs", 300);
|
|
104
|
+
t(this, "initialZoom");
|
|
105
|
+
t(this, "initialCenter");
|
|
106
|
+
t(this, "initialHeading");
|
|
107
|
+
t(this, "handleVisibilityChange", () => {
|
|
108
|
+
document.hidden ? this.pause() : this.resume();
|
|
109
|
+
});
|
|
110
|
+
const s = { ...T, ...e.webcam }, i = { ...D, ...e.tuning };
|
|
111
|
+
this.config = {
|
|
112
|
+
map: e.map,
|
|
113
|
+
webcam: s,
|
|
114
|
+
tuning: i,
|
|
115
|
+
debug: e.debug ?? !1
|
|
116
|
+
}, this.initialZoom = e.map.getZoom() ?? 10, this.initialCenter = e.map.getCenter() ?? null, this.initialHeading = e.map.getHeading() ?? 0, this.gestureController = new S(i, (r) => {
|
|
117
|
+
this.lastFrame = r;
|
|
118
|
+
}), this.stateMachine = new L(i), this.overlay = new G(s), this.interaction = new p(e.map);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Initialise webcam + MediaPipe, mount overlay, begin detection loop.
|
|
122
|
+
* Must be called from a user-gesture event (e.g. button click) to allow
|
|
123
|
+
* webcam permission prompt.
|
|
124
|
+
*/
|
|
125
|
+
async start() {
|
|
126
|
+
if (!this.started)
|
|
127
|
+
try {
|
|
128
|
+
const e = await this.gestureController.init();
|
|
129
|
+
this.overlay.attachVideo(e);
|
|
130
|
+
const s = this.config.map.getDiv();
|
|
131
|
+
this.overlay.mount(s ?? document.body), this.resetTransientState(), this.started = !0, this.paused = !1, this.interaction.syncFromMap(), this.gestureController.start(), this.renderLoop(), document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
|
132
|
+
} catch (e) {
|
|
133
|
+
throw this.overlay.unmount(), this.gestureController.destroy(), this.resetTransientState(), this.started = !1, this.paused = !1, document.removeEventListener("visibilitychange", this.handleVisibilityChange), e;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Stop detection and remove overlay. Safe to call start() again afterwards. */
|
|
137
|
+
stop() {
|
|
138
|
+
this.gestureController.destroy(), this.overlay.unmount(), this.stateMachine.reset(), this.resetTransientState(), this.interaction.dispose(), this.interaction = new p(this.config.map), this.rafHandle !== null && (cancelAnimationFrame(this.rafHandle), this.rafHandle = null), document.removeEventListener("visibilitychange", this.handleVisibilityChange), this.started = !1, this.paused = !1;
|
|
139
|
+
}
|
|
140
|
+
/** Pause detection (overlay stays visible but inactive). */
|
|
141
|
+
pause() {
|
|
142
|
+
this.paused = !0, this.gestureController.stop(), this.stateMachine.reset();
|
|
143
|
+
}
|
|
144
|
+
/** Resume after pause. */
|
|
145
|
+
resume() {
|
|
146
|
+
this.paused && (this.paused = !1, this.interaction.syncFromMap(), this.gestureController.start());
|
|
147
|
+
}
|
|
148
|
+
renderLoop() {
|
|
149
|
+
if (this.rafHandle = requestAnimationFrame(() => this.renderLoop()), this.paused) {
|
|
150
|
+
this.overlay.render(null, "idle");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const e = this.lastFrame;
|
|
154
|
+
if (e === null) {
|
|
155
|
+
this.overlay.render(null, "idle");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const s = this.stateMachine.update(e);
|
|
159
|
+
this.interaction.apply(s);
|
|
160
|
+
let i = 0;
|
|
161
|
+
const { leftHand: r, rightHand: a, timestamp: n } = e;
|
|
162
|
+
if (!!r && !!a && r.gesture !== "fist" && r.gesture !== "pinch" && a.gesture !== "fist" && a.gesture !== "pinch" && this.isPrayPose(r.landmarks, a.landmarks)) {
|
|
163
|
+
this.resetPoseGraceTimer = null, this.resetPoseStart === null && (this.resetPoseStart = n, this.resetPoseTriggered = !1);
|
|
164
|
+
const h = n - this.resetPoseStart;
|
|
165
|
+
i = Math.min(1, h / this.resetPoseDurationMs), !this.resetPoseTriggered && i >= 1 && (this.resetPoseTriggered = !0, this.config.map.moveCamera({
|
|
166
|
+
zoom: this.initialZoom,
|
|
167
|
+
center: this.initialCenter ?? void 0,
|
|
168
|
+
heading: this.initialHeading
|
|
169
|
+
}), this.interaction.syncFromMap());
|
|
170
|
+
} else if (this.resetPoseStart !== null) {
|
|
171
|
+
if (this.resetPoseGraceTimer === null ? this.resetPoseGraceTimer = n : n - this.resetPoseGraceTimer >= this.resetPoseGraceMs && (this.resetPoseStart = null, this.resetPoseTriggered = !1, this.resetPoseGraceTimer = null), this.resetPoseStart !== null) {
|
|
172
|
+
const h = n - this.resetPoseStart;
|
|
173
|
+
i = Math.min(1, h / this.resetPoseDurationMs);
|
|
174
|
+
}
|
|
175
|
+
} else
|
|
176
|
+
this.resetPoseGraceTimer = null;
|
|
177
|
+
this.overlay.render(e, s.mode, i), this.config.debug && this.logDebug(s.mode, e);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Returns true when both hands are held close together based on wrist proximity.
|
|
181
|
+
* Uses Euclidean distance between the two wrists in normalised screen-space
|
|
182
|
+
* coordinates (0 to 1). Threshold is 0.45.
|
|
183
|
+
*/
|
|
184
|
+
isPrayPose(e, s) {
|
|
185
|
+
const i = e[u.WRIST], r = s[u.WRIST];
|
|
186
|
+
if (!i || !r) return !1;
|
|
187
|
+
const a = i.x - r.x, n = i.y - r.y;
|
|
188
|
+
return Math.sqrt(a * a + n * n) < 0.45;
|
|
189
|
+
}
|
|
190
|
+
resetTransientState() {
|
|
191
|
+
this.lastFrame = null, this.resetPoseStart = null, this.resetPoseTriggered = !1, this.resetPoseGraceTimer = null;
|
|
192
|
+
}
|
|
193
|
+
logDebug(e, s) {
|
|
194
|
+
const i = s.hands.map((r) => `${r.handedness}:${r.gesture}(${r.score.toFixed(2)})`).join(" ");
|
|
195
|
+
console.debug(`[gmaps-gestures] mode=${e} ${i}`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
export {
|
|
199
|
+
x as COLORS,
|
|
200
|
+
w as DEFAULT_TUNING_CONFIG,
|
|
201
|
+
I as DEFAULT_WEBCAM_CONFIG,
|
|
202
|
+
E as GestureController,
|
|
203
|
+
Z as GestureMapController,
|
|
204
|
+
N as GestureStateMachine,
|
|
205
|
+
p as GoogleMapsGestureInteraction,
|
|
206
|
+
W as LANDMARKS,
|
|
207
|
+
_ as WebcamOverlay,
|
|
208
|
+
O as classifyGesture,
|
|
209
|
+
R as createHandClassifier,
|
|
210
|
+
U as getHandSize,
|
|
211
|
+
V as getTwoHandDistance
|
|
212
|
+
};
|
package/dist/types.d.ts
ADDED
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@map-gesture-controls/google-maps",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Control Google Maps with hand gestures via 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": "./style.css"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"style.css"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build:lib": "tsc -p tsconfig.lib.json && vite build --config vite.lib.config.ts",
|
|
22
|
+
"type-check": "tsc --noEmit"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@map-gesture-controls/core": "*"
|
|
26
|
+
},
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@types/google.maps": ">=3.0.0"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/google.maps": "^3.58.1",
|
|
32
|
+
"typescript": "^5.4.5",
|
|
33
|
+
"vite": "^5.2.11"
|
|
34
|
+
},
|
|
35
|
+
"keywords": [
|
|
36
|
+
"google-maps",
|
|
37
|
+
"googlemaps",
|
|
38
|
+
"gmaps",
|
|
39
|
+
"mediapipe",
|
|
40
|
+
"hand-gestures",
|
|
41
|
+
"gesture-control",
|
|
42
|
+
"gesture-recognition",
|
|
43
|
+
"hand-tracking",
|
|
44
|
+
"map",
|
|
45
|
+
"maps",
|
|
46
|
+
"webcam",
|
|
47
|
+
"touchless",
|
|
48
|
+
"hands-free",
|
|
49
|
+
"accessibility",
|
|
50
|
+
"kiosk",
|
|
51
|
+
"computer-vision",
|
|
52
|
+
"wasm",
|
|
53
|
+
"interaction"
|
|
54
|
+
],
|
|
55
|
+
"homepage": "https://sanderdesnaijer.github.io/map-gesture-controls/",
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "https://github.com/sanderdesnaijer/map-gesture-controls.git",
|
|
59
|
+
"directory": "packages/google-maps-gesture-controls"
|
|
60
|
+
},
|
|
61
|
+
"bugs": {
|
|
62
|
+
"url": "https://github.com/sanderdesnaijer/map-gesture-controls/issues"
|
|
63
|
+
},
|
|
64
|
+
"author": "Sander de Snaijer",
|
|
65
|
+
"overrides": {
|
|
66
|
+
"esbuild": "^0.25.0"
|
|
67
|
+
},
|
|
68
|
+
"license": "MIT"
|
|
69
|
+
}
|
package/style.css
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/* Shared overlay chrome for WebcamOverlay.
|
|
2
|
+
Layout-critical styles remain inline at runtime. */
|
|
3
|
+
.ol-gesture-overlay {
|
|
4
|
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
|
|
5
|
+
transition: box-shadow 0.3s ease;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
.ol-gesture-overlay:hover {
|
|
9
|
+
box-shadow: 0 4px 32px rgba(68, 136, 255, 0.35);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
.ol-gesture-video {
|
|
13
|
+
display: block;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.ol-gesture-badge {
|
|
17
|
+
position: absolute;
|
|
18
|
+
top: 8px;
|
|
19
|
+
left: 8px;
|
|
20
|
+
padding: 3px 10px;
|
|
21
|
+
border-radius: 12px;
|
|
22
|
+
font-family: system-ui, sans-serif;
|
|
23
|
+
font-size: 11px;
|
|
24
|
+
font-weight: 600;
|
|
25
|
+
letter-spacing: 0.06em;
|
|
26
|
+
text-transform: uppercase;
|
|
27
|
+
pointer-events: none;
|
|
28
|
+
transition: background 0.2s, color 0.2s;
|
|
29
|
+
z-index: 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.ol-gesture-badge--idle {
|
|
33
|
+
background: rgba(80, 80, 80, 0.75);
|
|
34
|
+
color: #ccc;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.ol-gesture-badge--panning {
|
|
38
|
+
background: rgba(0, 204, 255, 0.85);
|
|
39
|
+
color: #000;
|
|
40
|
+
box-shadow: 0 0 8px rgba(0, 204, 255, 0.6);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.ol-gesture-badge--zooming {
|
|
44
|
+
background: rgba(0, 255, 204, 0.85);
|
|
45
|
+
color: #000;
|
|
46
|
+
box-shadow: 0 0 8px rgba(0, 255, 204, 0.6);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
.ol-gesture-reset {
|
|
50
|
+
position: absolute;
|
|
51
|
+
bottom: 8px;
|
|
52
|
+
left: 8px;
|
|
53
|
+
right: 8px;
|
|
54
|
+
pointer-events: none;
|
|
55
|
+
transition: opacity 0.15s ease;
|
|
56
|
+
z-index: 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.ol-gesture-reset-label {
|
|
60
|
+
display: block;
|
|
61
|
+
font-family: system-ui, sans-serif;
|
|
62
|
+
font-size: 10px;
|
|
63
|
+
font-weight: 600;
|
|
64
|
+
letter-spacing: 0.06em;
|
|
65
|
+
text-transform: uppercase;
|
|
66
|
+
color: #fff;
|
|
67
|
+
margin-bottom: 3px;
|
|
68
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.ol-gesture-reset-track {
|
|
72
|
+
height: 4px;
|
|
73
|
+
background: rgba(255, 255, 255, 0.2);
|
|
74
|
+
border-radius: 2px;
|
|
75
|
+
overflow: hidden;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.ol-gesture-reset-fill {
|
|
79
|
+
height: 100%;
|
|
80
|
+
width: 0%;
|
|
81
|
+
background: rgba(255, 200, 0, 0.9);
|
|
82
|
+
border-radius: 2px;
|
|
83
|
+
box-shadow: 0 0 6px rgba(255, 200, 0, 0.6);
|
|
84
|
+
transition: width 0.05s linear;
|
|
85
|
+
}
|