<script type="text/javascript">
let ws = null;
let clientId = "thumb_" + Math.random().toString(36).substr(2, 9); // Unique per session
window.phaseState = 0; // 0 = idle, 1 = calibrating, 2 = active, 3 = done
window.neutralPose = null;
const FRAMES_PER_BATCH = 1; // Send 1 frame at a time for real-time
let calibrationStart = null;
// ====================== 🟢 WEBSOCKET SETUP ======================
function startCircumductionWebSocket() {
ws = new WebSocket(`wss://${window.location.host}/api/v1/thumb/circumduction/${clientId}`);
ws.onopen = () => {
window.phaseState = 1;
calibrationStart = Date.now();
console.log("WebSocket connected");
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.phase === "neutral_pose") {
window.neutralPose = msg.neutralPose;
window.phaseState = 2;
drawExpectedCircle(window.neutralPose, document.querySelector('#webcam'), document.querySelector('#output_canvas'));
document.dispatchEvent(new CustomEvent("calibration:complete", { detail: { at: Date.now() } }));
} else if (msg.phase === "circumduction") {
// Update UI with metrics and ideal circle
// Example: paintThumbPanel('Right', { Current: msg.metrics });
// Optionally, draw ideal circle using msg.ideal_circle
} else if (msg.phase === "done") {
window.phaseState = 3;
// Show completion UI
}
};
ws.onclose = () => {
window.phaseState = 0;
console.log("WebSocket closed");
};
ws.onerror = (err) => {
console.error("WebSocket error:", err);
};
}
// ====================== 🟢 SEND FRAMES ======================
function sendHandFrame(frame) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ handFrame: frame }));
}
}
// ====================== 🟢 MAIN LOOP ======================
function thumbWebSocketLoop() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
setInterval(() => {
if (window.phaseState === 1 || window.phaseState === 2) {
if (typeof LandMarkData !== "undefined" && LandMarkData.length > 0) {
const frame = LandMarkData[LandMarkData.length - 1];
sendHandFrame(frame);
}
}
}, 50); // Send every 50ms for real-time
}
// ====================== 🟢 CALIBRATION FLOW ======================
function setupCalibrationFlow() {
const btn = document.querySelector("#webcamButton");
const video = document.querySelector("#webcam");
const webcamContainer = document.querySelector(".webcam-con");
if (!btn || !video) return;
btn.addEventListener("click", async () => {
if (webcamContainer) webcamContainer.style.display = "block";
video.addEventListener("playing", function onPlay() {
video.removeEventListener("playing", onPlay);
startCircumductionWebSocket();
thumbWebSocketLoop();
});
});
document.addEventListener("calibration:complete", () => {
// Optionally start additional UI updates or loops
});
}
// ====================== 🟢 DRAW EXPECTED CIRCLE ======================
function drawExpectedCircle(neutral = null, videoEl = null, canvasEl = null) {
try {
const video = videoEl || document.querySelector('#webcam');
const canvas = canvasEl || document.querySelector('#output_canvas');
const n = neutral || window.neutralPose;
if (!video || !canvas) return false;
if (!n || n.thumbCmcX == null || n.thumbCmcY == null || n.thumbTipX == null || n.thumbTipY == null) return false;
if (!ensureOverlayReady(video, canvas)) return false;
const cssW = canvas.clientWidth, cssH = canvas.clientHeight;
const cmc_cx = n.thumbCmcX * cssW, cmc_cy = n.thumbCmcY * cssH;
const tip_cx = n.thumbTipX * cssW, tip_cy = n.thumbTipY * cssH;
const dx = tip_cx - cmc_cx, dy = tip_cy - cmc_cy;
const r = Math.sqrt(dx * dx + dy * dy);
const ctx = canvas.getContext('2d');
if (!ctx) return false;
ctx.save();
ctx.beginPath();
ctx.setLineDash([8, 8]);
ctx.lineWidth = 3;
ctx.strokeStyle = '#0d6efd';
ctx.arc(cmc_cx, cmc_cy, r, 0, Math.PI * 2);
ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath();
ctx.fillStyle = '#000000';
ctx.arc(cmc_cx, cmc_cy, 4, 0, Math.PI * 2);
ctx.fill();
if (n.thumbMcpX != null && n.thumbMcpY != null) {
const mcp_cx = n.thumbMcpX * cssW, mcp_cy = n.thumbMcpY * cssH;
ctx.beginPath();
ctx.fillStyle = '#198754';
ctx.arc(mcp_cx, mcp_cy, 4, 0, Math.PI * 2);
ctx.fill();
}
ctx.beginPath();
ctx.fillStyle = '#dc3545';
ctx.arc(tip_cx, tip_cy, 4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
return true;
} catch (err) {
console.warn('drawExpectedCircle failed:', err);
return false;
}
}
function ensureOverlayReady(videoEl, canvasEl) {
const video = videoEl || document.querySelector('#webcam');
const canvas = canvasEl || document.querySelector('#output_canvas');
if (!video || !canvas) return false;
const vw = video.videoWidth, vh = video.videoHeight;
const vb = video.getBoundingClientRect();
const cssW = Math.round(vb.width || video.clientWidth || vw);
const cssH = Math.round(vb.height || video.clientHeight || vh);
if (!canvas.style.width) canvas.style.width = cssW + 'px';
if (!canvas.style.height) canvas.style.height = cssH + 'px';
if (!canvas.style.position) canvas.style.position = 'absolute';
if (!canvas.style.left) canvas.style.left = '0px';
if (!canvas.style.top) canvas.style.top = '0px';
const dpr = window.devicePixelRatio || 1;
const targetW = Math.round(cssW * dpr);
const targetH = Math.round(cssH * dpr);
if (canvas.width !== targetW) canvas.width = targetW;
if (canvas.height !== targetH) canvas.height = targetH;
const ctx = canvas.getContext('2d');
if (!ctx) return false;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return true;
}
// ====================== 🚀 ORCHESTRATOR ======================
function main() {
setupCalibrationFlow();
// Other setup as needed
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', main);
} else {
main();
}
</script>