@theforce/core
Version:
Core library for TheForce hand tracking
262 lines (259 loc) • 9.03 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => {
__defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
return value;
};
// src/utils.ts
var getCursorScreenCoordinates = (landmark, config) => {
const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const normalizedXFromCenter = 0.5 - landmark.x;
const normalizedYFromCenter = landmark.y - 0.5;
const scaledXFromCenter = normalizedXFromCenter * (config.sensitivityX || 1);
const scaledYFromCenter = normalizedYFromCenter * (config.sensitivityY || 1);
const clampedScaledX = Math.max(-0.5, Math.min(0.5, scaledXFromCenter));
const clampedScaledY = Math.max(-0.5, Math.min(0.5, scaledYFromCenter));
const x = centerX + clampedScaledX * window.innerWidth;
const y = centerY + clampedScaledY * window.innerHeight;
return { x, y };
};
// src/hand-tracker.ts
var HandTracker = class {
constructor(config = {}) {
__publicField(this, "hands");
// Changed to any
__publicField(this, "camera", null);
// Changed to any
__publicField(this, "videoElement");
__publicField(this, "canvasElement");
__publicField(this, "cursorElement", null);
__publicField(this, "config");
__publicField(this, "onResultsCallback", null);
__publicField(this, "hoverTimeout", null);
__publicField(this, "hoveredElement", null);
__publicField(this, "isInitialized", false);
this.config = {
hoverDelay: 2e3,
sensitivityX: 1,
sensitivityY: 1,
cursorLandmarkIndex: 9,
// Default to middle finger base
...config
};
this.hands = new window.Hands({
locateFile: (file) => {
return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`;
}
});
this.videoElement = document.createElement("video");
this.videoElement.autoplay = true;
this.videoElement.muted = true;
this.videoElement.playsInline = true;
this.videoElement.style.display = "none";
this.canvasElement = document.createElement("canvas");
this.canvasElement.style.display = "none";
if (this.config.debug) {
this.videoElement.style.display = "block";
this.videoElement.style.position = "fixed";
this.videoElement.style.bottom = "10px";
this.videoElement.style.right = "10px";
this.videoElement.style.width = "200px";
this.videoElement.style.height = "150px";
this.videoElement.style.zIndex = "9999";
document.body.appendChild(this.videoElement);
this.canvasElement.style.display = "block";
this.canvasElement.style.position = "fixed";
this.canvasElement.style.bottom = "10px";
this.canvasElement.style.right = "10px";
this.canvasElement.style.width = "200px";
this.canvasElement.style.height = "150px";
this.canvasElement.style.zIndex = "10000";
document.body.appendChild(this.canvasElement);
}
this.setupHands();
}
setupHands() {
this.hands.setOptions({
maxNumHands: 1,
modelComplexity: 1,
minDetectionConfidence: 0.9,
minTrackingConfidence: 0.9
});
this.hands.onResults((results) => {
this.handleResults(results);
});
}
handleResults(results) {
if (this.onResultsCallback) {
this.onResultsCallback(results);
}
if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
const landmarks = results.multiHandLandmarks[0];
this.updateCursor(landmarks);
this.checkHoverableElements(landmarks);
}
}
_getCursorScreenCoordinates(landmark) {
return getCursorScreenCoordinates(landmark, {
sensitivityX: this.config.sensitivityX,
sensitivityY: this.config.sensitivityY
});
}
updateCursor(landmarks) {
if (!this.cursorElement || !landmarks || landmarks.length === 0)
return;
const cursorLandmark = landmarks[this.config.cursorLandmarkIndex || 9];
if (cursorLandmark) {
const { x, y } = this._getCursorScreenCoordinates(cursorLandmark);
this.cursorElement.style.left = `${x}px`;
this.cursorElement.style.top = `${y}px`;
this.cursorElement.style.display = "block";
}
}
checkHoverableElements(landmarks) {
if (!landmarks || landmarks.length === 0)
return;
const cursorLandmark = landmarks[this.config.cursorLandmarkIndex || 9];
if (!cursorLandmark)
return;
const { x, y } = this._getCursorScreenCoordinates(cursorLandmark);
const element = document.elementFromPoint(x, y);
const hoverableElement = element?.closest(
"[data-hoverable]"
);
if (hoverableElement && hoverableElement !== this.hoveredElement) {
this.handleHoverStart(hoverableElement);
} else if (!hoverableElement && this.hoveredElement) {
this.handleHoverEnd();
}
}
handleHoverStart(element) {
this.hoveredElement = element;
element.classList.add("force-hover");
if (this.cursorElement) {
this.cursorElement.classList.add("force-loading");
this.cursorElement.style.setProperty(
"--hover-delay",
`${this.config.hoverDelay || 2e3}ms`
);
}
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout);
}
this.hoverTimeout = setTimeout(() => {
this.triggerClick(element);
}, this.config.hoverDelay || 2e3);
}
handleHoverEnd() {
if (this.hoveredElement) {
this.hoveredElement.classList.remove("force-hover");
this.hoveredElement = null;
}
if (this.cursorElement) {
this.cursorElement.classList.remove("force-loading");
}
if (this.hoverTimeout) {
clearTimeout(this.hoverTimeout);
this.hoverTimeout = null;
}
}
triggerClick(element) {
const event = new MouseEvent("click", {
bubbles: true,
cancelable: true,
view: window
});
element.dispatchEvent(event);
}
async initialize() {
if (this.isInitialized)
return;
this.cursorElement = document.createElement("div");
this.cursorElement.style.position = "fixed";
this.cursorElement.style.pointerEvents = "none";
this.cursorElement.style.zIndex = "9999";
this.cursorElement.style.display = "none";
this.cursorElement.style.width = "20px";
this.cursorElement.style.height = "20px";
this.cursorElement.style.borderRadius = "50%";
this.cursorElement.style.backgroundColor = "red";
this.cursorElement.style.opacity = "0.7";
this.cursorElement.style.transform = "translate(-50%, -50%)";
this.cursorElement.style.transition = "all 0.2s";
const style = document.createElement("style");
style.innerHTML = `
.force-cursor {
position: fixed;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: red;
opacity: 0.7;
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
transition: transform 0.2s ease-out;
}
force-spin {
to {
transform: rotate(360deg);
}
}
`;
document.head.appendChild(style);
if (this.config.cursorImageUrl) {
this.cursorElement.style.backgroundImage = `url(${this.config.cursorImageUrl})`;
this.cursorElement.style.backgroundSize = "contain";
this.cursorElement.style.backgroundColor = "transparent";
}
document.body.appendChild(this.cursorElement);
this.isInitialized = true;
}
async start() {
if (!this.isInitialized) {
await this.initialize();
}
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
this.videoElement.srcObject = stream;
await this.videoElement.play();
this.canvasElement.width = this.videoElement.videoWidth;
this.canvasElement.height = this.videoElement.videoHeight;
}
this.camera = new window.Camera(this.videoElement, {
onFrame: async () => {
if (this.videoElement) {
await this.hands.send({ image: this.videoElement });
}
},
width: 640,
height: 480
});
await this.camera.start();
}
async stop() {
if (this.camera) {
await this.camera.stop();
this.camera = null;
}
if (this.videoElement && this.videoElement.parentElement) {
this.videoElement.parentElement.removeChild(this.videoElement);
}
if (this.canvasElement && this.canvasElement.parentElement) {
this.canvasElement.parentElement.removeChild(this.canvasElement);
}
if (this.cursorElement && this.cursorElement.parentElement) {
this.cursorElement.parentElement.removeChild(this.cursorElement);
this.cursorElement = null;
}
this.handleHoverEnd();
this.isInitialized = false;
}
onResults(callback) {
this.onResultsCallback = callback;
}
};
export {
HandTracker
};