STRAPS_LOCALHOST/integrated.js

3380 lines
98 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Integrated Fall Detection & Rehab Medic JavaScript
// Combines both features with improved state management and controls
// Includes ROI Transform for Sleeping Detection
// Extended with 7 medical rehabilitation exercises and Rehab Mode
// Updated with Firebase integration, authentication, and enhanced UI
import {
PoseLandmarker,
FilesetResolver,
} from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14";
// Import config loader
import { loadConfig, getConfig } from "./config.js";
// Import Firebase config (async loading for compatibility)
let firebaseModule = null;
async function loadFirebase() {
try {
firebaseModule = await import("./firebase-config.js");
return firebaseModule;
} catch (error) {
console.warn("Firebase module not available:", error);
return null;
}
}
// ========== DOM ELEMENTS ==========
const UI = {
// Camera
video: document.getElementById("video"),
overlay: document.getElementById("overlay"),
roiCanvas: document.getElementById("roi-canvas"),
cameraContainer: document.getElementById("camera-container"),
cameraPlaceholder: document.getElementById("camera-placeholder"),
loadingOverlay: document.getElementById("loading-overlay"),
statusText: document.getElementById("status-text"),
statusBadge: document.getElementById("status-badge"),
// Toggles
toggleCamera: document.getElementById("toggle-camera"),
toggleFall: document.getElementById("toggle-fall"),
toggleRehab: document.getElementById("toggle-rehab"),
fallToggleItem: document.getElementById("fall-toggle-item"),
rehabToggleItem: document.getElementById("rehab-toggle-item"),
// ROI Panel
roiPanel: document.getElementById("roi-panel"),
roiEdit: document.getElementById("roi-edit"),
roiSave: document.getElementById("roi-save"),
roiCancel: document.getElementById("roi-cancel"),
roiDelete: document.getElementById("roi-delete"),
roiStatusText: document.getElementById("roi-status-text"),
// Fall Detection Info
fallInfoPanel: document.getElementById("fall-info-panel"),
fallStatus: document.getElementById("fall-status"),
fallConfidence: document.getElementById("fall-confidence"),
helpGesture: document.getElementById("help-gesture"),
sleepingStatus: document.getElementById("sleeping-status"),
fallTimer: document.getElementById("fall-timer"),
// Rehab Medic Info
rehabInfoPanel: document.getElementById("rehab-info-panel"),
exerciseSelect: document.getElementById("exercise-select"),
exerciseTitle: document.getElementById("exercise-title"),
statLabelLeft: document.getElementById("stat-label-left"),
statLabelRight: document.getElementById("stat-label-right"),
repsLeft: document.getElementById("reps-left"),
repsRight: document.getElementById("reps-right"),
stageLeft: document.getElementById("stage-left"),
stageRight: document.getElementById("stage-right"),
angleLeft: document.getElementById("angle-left"),
angleRight: document.getElementById("angle-right"),
fpsDisplay: document.getElementById("fps-display"),
poseStatus: document.getElementById("pose-status"),
resetCounter: document.getElementById("reset-counter"),
// Standard Exercise Panel
standardExercisePanel: document.getElementById("standard-exercise-panel"),
// Form Accuracy Elements
formAccuracyPanel: document.getElementById("form-accuracy-panel"),
accuracyValue: document.getElementById("accuracy-value"),
meanErrorValue: document.getElementById("mean-error-value"),
accuracyBar: document.getElementById("accuracy-bar"),
// Rehab Mode Elements
rehabModeSetup: document.getElementById("rehab-mode-setup"),
rehabModeActive: document.getElementById("rehab-mode-active"),
rehabModeComplete: document.getElementById("rehab-mode-complete"),
exerciseQueue: document.getElementById("exercise-queue"),
addExerciseType: document.getElementById("add-exercise-type"),
addExerciseReps: document.getElementById("add-exercise-reps"),
addExerciseBtn: document.getElementById("add-exercise-btn"),
startRehabBtn: document.getElementById("start-rehab-btn"),
clearQueueBtn: document.getElementById("clear-queue-btn"),
resetRehabBtn: document.getElementById("reset-rehab-btn"),
restartRehabBtn: document.getElementById("restart-rehab-btn"),
currentExerciseName: document.getElementById("current-exercise-name"),
rehabProgressLeft: document.getElementById("rehab-progress-left"),
rehabProgressRight: document.getElementById("rehab-progress-right"),
rehabTargetLeft: document.getElementById("rehab-target-left"),
rehabTargetRight: document.getElementById("rehab-target-right"),
progressQueue: document.getElementById("progress-queue"),
rehabTotalExercises: document.getElementById("rehab-total-exercises"),
rehabCompletedExercises: document.getElementById("rehab-completed-exercises"),
fpsDisplayRehab: document.getElementById("fps-display-rehab"),
poseStatusRehab: document.getElementById("pose-status-rehab"),
rehabCompleteStats: document.getElementById("rehab-complete-stats"),
// Enlarged Rehab Stats (new)
rehabBigLeft: document.getElementById("rehab-big-left"),
rehabBigRight: document.getElementById("rehab-big-right"),
rehabTargetLeftBig: document.getElementById("rehab-target-left-big"),
rehabTargetRightBig: document.getElementById("rehab-target-right-big"),
storageIndicatorActive: document.getElementById("storage-indicator-active"),
// Angles Panel
anglesPanel: document.getElementById("angles-panel"),
angLeftElbow: document.getElementById("ang-left-elbow"),
angRightElbow: document.getElementById("ang-right-elbow"),
angLeftShoulder: document.getElementById("ang-left-shoulder"),
angRightShoulder: document.getElementById("ang-right-shoulder"),
angLeftHip: document.getElementById("ang-left-hip"),
angRightHip: document.getElementById("ang-right-hip"),
angLeftKnee: document.getElementById("ang-left-knee"),
angRightKnee: document.getElementById("ang-right-knee"),
// Toast & Audio
toast: document.getElementById("toast"),
audio: document.getElementById("alert-sound"),
// Status Indicators (new)
bardiStatus: document.getElementById("bardi-status"),
telegramStatus: document.getElementById("telegram-status"),
// User Profile Elements (new)
userProfile: document.getElementById("user-profile"),
userAvatar: document.getElementById("user-avatar"),
userName: document.getElementById("user-name"),
logoutBtn: document.getElementById("logout-btn"),
guestBadge: document.getElementById("guest-badge"),
// Calibration Modal (new)
calibrationModal: document.getElementById("calibration-modal"),
calibrationStatus: document.getElementById("calibration-status"),
calibrationStatusIcon: document.getElementById("calibration-status-icon"),
calibrationStatusText: document.getElementById("calibration-status-text"),
calibrationConfirmBtn: document.getElementById("calibration-confirm-btn"),
calibrationSkipBtn: document.getElementById("calibration-skip-btn"),
// History Panel Elements
historyPanel: document.getElementById("history-panel"),
historyRefreshBtn: document.getElementById("history-refresh-btn"),
historyGuestWarning: document.getElementById("history-guest-warning"),
historyLoginBtn: document.getElementById("history-login-btn"),
historyLoading: document.getElementById("history-loading"),
historyEmpty: document.getElementById("history-empty"),
historyList: document.getElementById("history-list"),
historyLoadMore: document.getElementById("history-load-more"),
};
// ========== TELEGRAM CONFIG ==========
// Configuration will be loaded from server via config.js OR use window fallback
const TELEGRAM = {
enabled: false,
mode: "proxy",
proxyUrl: "",
botToken: "",
// chatId removed - broadcast mode to all subscribers
cooldownS: 60,
};
// Function to initialize Telegram config from server or window
async function initTelegramConfig() {
try {
// First, check if window.TELEGRAM_CONFIG is available (immediate fallback)
if (window.TELEGRAM_CONFIG) {
TELEGRAM.proxyUrl = window.TELEGRAM_CONFIG.proxyUrl || "";
// chatId not used - broadcast mode
TELEGRAM.enabled = window.TELEGRAM_CONFIG.enabled || false;
TELEGRAM.cooldownS = window.TELEGRAM_CONFIG.cooldownS || 60;
console.log("[Telegram] ✅ Using window.TELEGRAM_CONFIG (immediate)");
}
// Then try to load from worker (will override if successful)
await loadConfig();
const workerUrl = getConfig("TELEGRAM_BOT_URL");
if (workerUrl) {
TELEGRAM.proxyUrl = workerUrl;
// chatId not used - broadcast mode
TELEGRAM.enabled = getConfig("TELEGRAM_ENABLED") || TELEGRAM.enabled;
TELEGRAM.cooldownS =
getConfig("TELEGRAM_COOLDOWN_SECONDS") || TELEGRAM.cooldownS;
console.log("[Telegram] ✅ Configuration loaded from worker");
}
console.log("[Telegram] Final config:", {
enabled: TELEGRAM.enabled,
proxyUrl: TELEGRAM.proxyUrl ? "✓ configured" : "✗ not configured",
mode: "broadcast to all subscribers",
});
} catch (error) {
console.error(
"[Telegram] ⚠️ Worker config failed, using fallback:",
error
);
// Fallback to window.TELEGRAM_CONFIG if worker fails (already set above)
}
}
// ========== EXERCISE CONFIGURATIONS ==========
// Modular exercise configuration for easy addition of new exercises
// 7 medical rehabilitation exercises
const EXERCISES = {
bicep_curls: {
name: "Bicep Curl",
description: "Counter bicep curl untuk kedua tangan",
// Track both arms separately
trackBothSides: true,
labelLeft: "Tangan Kiri",
labelRight: "Tangan Kanan",
// Angle thresholds for bicep curls (elbow angle: shoulder-elbow-wrist)
upThreshold: 30, // Arm curled up position (triggers rep count)
downThreshold: 160, // Arm extended down position (triggers "down" stage)
// Ideal angles for form accuracy calculation
idealUpAngle: 30,
idealDownAngle: 160,
// Counter logic
downCompare: ">",
upCompare: "<",
// Joints to track
joints: {
left: { a: "LEFT_SHOULDER", b: "LEFT_ELBOW", c: "LEFT_WRIST" },
right: { a: "RIGHT_SHOULDER", b: "RIGHT_ELBOW", c: "RIGHT_WRIST" },
},
},
knee_extension: {
name: "Knee Extension",
description: "Latihan untuk quadriceps dan stabilitas lutut",
trackBothSides: true,
labelLeft: "Lutut Kiri",
labelRight: "Lutut Kanan",
// Knee angle (hip-knee-ankle): Flexed <100°, Extended >155°
upThreshold: 100, // Flexed position
downThreshold: 155, // Extended position
idealUpAngle: 90,
idealDownAngle: 165,
// Counter logic: "down" when extended (angle > downThreshold), "up" when flexed
downCompare: ">",
upCompare: "<",
joints: {
left: { a: "LEFT_HIP", b: "LEFT_KNEE", c: "LEFT_ANKLE" },
right: { a: "RIGHT_HIP", b: "RIGHT_KNEE", c: "RIGHT_ANKLE" },
},
},
front_raise: {
name: "Front Raise",
description: "Melatih bahu bagian depan (Shoulder Flexion 90°)",
trackBothSides: true,
labelLeft: "Bahu Kiri",
labelRight: "Bahu Kanan",
// Shoulder angle (elbow-shoulder-hip): Down <30°, Up ≥80°
upThreshold: 30, // Down position
downThreshold: 80, // Up position
idealUpAngle: 10,
idealDownAngle: 90,
// Counter logic: "down" when raised (angle > downThreshold), "up" when lowered
downCompare: ">",
upCompare: "<",
joints: {
left: { a: "LEFT_ELBOW", b: "LEFT_SHOULDER", c: "LEFT_HIP" },
right: { a: "RIGHT_ELBOW", b: "RIGHT_SHOULDER", c: "RIGHT_HIP" },
},
},
shoulder_flexion: {
name: "Shoulder Flexion",
description: "Rehabilitasi bahu hingga 150°",
trackBothSides: true,
labelLeft: "Bahu Kiri",
labelRight: "Bahu Kanan",
// Shoulder angle (elbow-shoulder-hip): Down <30°, Up ≥110°
upThreshold: 30, // Down position
downThreshold: 110, // Up position (higher than front raise)
idealUpAngle: 10,
idealDownAngle: 140,
// Counter logic
downCompare: ">",
upCompare: "<",
joints: {
left: { a: "LEFT_ELBOW", b: "LEFT_SHOULDER", c: "LEFT_HIP" },
right: { a: "RIGHT_ELBOW", b: "RIGHT_SHOULDER", c: "RIGHT_HIP" },
},
},
sit_to_stand: {
name: "Sit to Stand",
description: "Latihan fungsional tubuh bagian bawah",
trackBothSides: false,
labelLeft: "Sudut Lutut",
labelRight: "Sudut Pinggul",
// Knee: Sitting <100°, Standing ≥155°
// Hip: Sitting <100°, Standing ≥140°
upThreshold: 100, // Sitting position
downThreshold: 155, // Standing position (knee)
idealUpAngle: 90,
idealDownAngle: 165,
downCompare: ">",
upCompare: "<",
// Note: joints config not used - specialLogic handles angle calculation
// Left displays knee angle, Right displays hip angle (both from left side of body)
joints: {
left: { a: "LEFT_HIP", b: "LEFT_KNEE", c: "LEFT_ANKLE" }, // Knee angle
right: { a: "LEFT_SHOULDER", b: "LEFT_HIP", c: "LEFT_KNEE" }, // Hip angle
},
specialLogic: "sit_to_stand", // Custom processing bypasses standard joint calculation
},
shoulder_abduction: {
name: "Shoulder Abduction",
description: "Lateral raise untuk bahu samping",
trackBothSides: true,
labelLeft: "Bahu Kiri",
labelRight: "Bahu Kanan",
// Shoulder lateral angle (wrist-shoulder-hip): Down <30°, Up ≥80°
upThreshold: 30, // Down position
downThreshold: 80, // Up position
idealUpAngle: 10,
idealDownAngle: 90,
// Counter logic
downCompare: ">",
upCompare: "<",
joints: {
left: { a: "LEFT_WRIST", b: "LEFT_SHOULDER", c: "LEFT_HIP" },
right: { a: "RIGHT_WRIST", b: "RIGHT_SHOULDER", c: "RIGHT_HIP" },
},
},
hip_abduction: {
name: "Hip Abduction",
description: "Memperkuat otot pinggul dan keseimbangan",
trackBothSides: true,
labelLeft: "Pinggul Kiri",
labelRight: "Pinggul Kanan",
// Hip angle (ankle-hip-shoulder): Neutral <20°, Abducted ≥45°
upThreshold: 20, // Neutral position
downThreshold: 45, // Abducted position
idealUpAngle: 5,
idealDownAngle: 50,
// Counter logic
downCompare: ">",
upCompare: "<",
joints: {
left: { a: "LEFT_ANKLE", b: "LEFT_HIP", c: "LEFT_SHOULDER" },
right: { a: "RIGHT_ANKLE", b: "RIGHT_HIP", c: "RIGHT_SHOULDER" },
},
},
};
// ========== CONFIGURATION ==========
const CONFIG = {
streamW: 640,
streamH: 360,
// Fall Detection Config
fall: {
confThreshold: 0.45,
horizontalAngleDeg: 55.0,
groundYRatio: 0.8,
suddenSpeedThresh: 280.0,
inactivityWindowS: 2.5,
inactivitySpeedThresh: 18.0,
sleepingConfidence: 0.0, // Confidence when sleeping is detected (safe)
help: {
sustainS: 1.5,
holdS: 6.0,
clearAfterQuietS: 2.0,
},
waving: {
minSwings: 2,
swingThreshold: 0.15,
timeWindow: 2.0,
handRaisedMinY: 0.1,
},
},
// Rehab Medic Config (default values, exercise-specific override these)
rehab: {
angleAlpha: 0.35, // Smoothing factor for angle EMA
},
// Form Accuracy Config
formAccuracy: {
maxAcceptableError: 30, // Maximum error in degrees for 0% accuracy
goodThreshold: 85, // Percentage threshold for "good" form (green)
warningThreshold: 60, // Percentage threshold for "warning" form (yellow)
},
// ROI Config
roi: {
cornerRadius: 6, // Corner circle radius in pixels
cornerHitDistance: 10, // Distance threshold for corner hit detection
epsilon: 1e-9, // Small value to prevent division by zero
},
};
// ========== UTILITY FUNCTION (needed before STATE) ==========
function emaFactory(alpha = 0.3) {
let v = null;
return (x) => {
v = v === null ? x : alpha * x + (1 - alpha) * v;
return v;
};
}
// ========== ROI STORAGE KEY ==========
const ROI_STORAGE_KEY = "bed_roi_rrect_v1";
// ========== STATE MANAGEMENT ==========
const STATE = {
// System state
landmarker: null,
stream: null,
cameraActive: false,
fallDetectionActive: false,
rehabActive: false,
running: false,
// FPS tracking
lastFrameT: performance.now(),
fpsHistory: [],
// ROI state for sleeping detection
editingROI: false,
roiDraftRRect: null,
roiDragCorner: -1,
roiLastMouse: null,
bedROI: null,
// Fall Detection State
fall: {
centerHist: [],
speedEMA: emaFactory(0.3),
lastSuddenT: null,
inFallWindow: false,
lastFallTriggerT: null,
// Waving detection
wavingNow: false,
wavingSince: 0,
lastWavingT: 0,
wristHistory: [],
swingCount: 0,
lastSwingDir: null,
// HELP state
helpActive: false,
helpSince: 0,
helpExpiresAt: 0,
// Telegram cooldown
lastHelpSent: 0,
lastFallSent: 0,
// Last status for toast
lastStatus: "SAFE",
},
// Rehab Medic State
rehab: {
currentExercise: "bicep_curls", // Current selected exercise
repsLeft: 0,
repsRight: 0,
repsCombined: 0, // For exercises that track both sides together (squats)
stageLeft: null,
stageRight: null,
stageCombined: null,
rawAngleLeft: null,
rawAngleRight: null,
smoothAngleLeft: null,
smoothAngleRight: null,
// Form accuracy tracking (sliding window of last 100 samples)
errorHistory: [], // Array of errors for mean calculation
currentFormAccuracy: null,
currentMeanError: null,
// Rehab Mode state
rehabModeActive: false, // Whether rehab mode workflow is active
rehabModePhase: "setup", // "setup", "active", "complete"
exerciseQueue: [], // Array of { type: string, reps: number, completedLeft: number, completedRight: number }
currentExerciseIndex: 0, // Index of current exercise in queue
startTime: null, // When the workout started
endTime: null, // When the workout ended
},
// Authentication State (new)
auth: {
user: null,
isGuest: false,
isAuthenticated: false,
},
// Calibration State (new)
calibration: {
isCalibrated: false,
poseDetected: false,
allLandmarksVisible: false,
pendingAction: null, // "fall" or "rehab" - what to enable after calibration
},
// Connection Status (new)
connections: {
bardiConnected: false,
telegramConnected: false,
lastBardiCheck: 0,
lastTelegramCheck: 0,
},
// History Panel State
history: {
items: [], // Array of history items
lastDoc: null, // Last document for pagination (Firestore)
hasMore: false, // Whether there are more items to load
isLoading: false, // Loading state
},
};
// ========== UTILITY FUNCTIONS ==========
function ema(prev, x, alpha) {
if (prev == null) return x;
return alpha * x + (1 - alpha) * prev;
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function angleBetween(a, b, c) {
if (!a || !b || !c) return 0;
const ba = [a[0] - b[0], a[1] - b[1]];
const bc = [c[0] - b[0], c[1] - b[1]];
const dot = ba[0] * bc[0] + ba[1] * bc[1];
const magBa = Math.hypot(ba[0], ba[1]);
const magBc = Math.hypot(bc[0], bc[1]);
if (magBa === 0 || magBc === 0) return 0;
const cos = Math.max(-1, Math.min(1, dot / (magBa * magBc)));
return (Math.acos(cos) * 180) / Math.PI;
}
function angleBetweenObj(a, b, c) {
if (!a || !b || !c) return 0;
const ba = { x: a.x - b.x, y: a.y - b.y };
const bc = { x: c.x - b.x, y: c.y - b.y };
const dot = ba.x * bc.x + ba.y * bc.y;
const magBa = Math.hypot(ba.x, ba.y);
const magBc = Math.hypot(bc.x, bc.y);
if (magBa === 0 || magBc === 0) return 0;
let cos = dot / (magBa * magBc);
cos = Math.min(1, Math.max(-1, cos));
return (Math.acos(cos) * 180) / Math.PI;
}
function mid(a, b) {
if (!a || !b) return null;
return [Math.round((a[0] + b[0]) / 2), Math.round((a[1] + b[1]) / 2)];
}
function dist(a, b) {
return Math.hypot(a[0] - b[0], a[1] - b[1]);
}
function nowS() {
return Date.now() / 1000;
}
function formatDuration(startTimeMs, endTimeMs) {
const durationS = Math.round((endTimeMs - startTimeMs) / 1000);
const minutes = Math.floor(durationS / 60);
const seconds = durationS % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
// ========== ROI UTILITY FUNCTIONS ==========
function dispToStreamPt(p, canvas) {
const sx = CONFIG.streamW / canvas.width;
const sy = CONFIG.streamH / canvas.height;
return { x: Math.round(p.x * sx), y: Math.round(p.y * sy) };
}
function streamToDispPt(p, canvas) {
const sx = canvas.width / CONFIG.streamW;
const sy = canvas.height / CONFIG.streamH;
return { x: Math.round(p.x * sx), y: Math.round(p.y * sy) };
}
function loadROI() {
const raw = localStorage.getItem(ROI_STORAGE_KEY);
if (!raw) return null;
try {
const obj = JSON.parse(raw);
if (
obj &&
obj.type === "rrect" &&
Array.isArray(obj.pts) &&
obj.pts.length === 4
) {
return obj;
}
} catch {}
return null;
}
function saveROI(roi) {
if (!roi) {
localStorage.removeItem(ROI_STORAGE_KEY);
return;
}
const toSave = {
type: "rrect",
pts: roi.pts.map((p) => ({ x: Math.round(p.x), y: Math.round(p.y) })),
};
localStorage.setItem(ROI_STORAGE_KEY, JSON.stringify(toSave));
}
function deleteROI() {
const hasROI = !!STATE.bedROI;
const hasDraft = !!STATE.roiDraftRRect;
if (!hasROI && !hasDraft) {
alert("Tidak ada ROI yang tersimpan.");
return;
}
if (!confirm("Hapus ROI?")) return;
STATE.bedROI = null;
STATE.roiDraftRRect = null;
saveROI(null);
setEditorUI(false);
drawROIOverlay();
updateROIStatus();
alert("ROI dihapus.");
}
function pointInQuad(ptStreamArr) {
const roi = STATE.bedROI;
if (!roi || roi.type !== "rrect" || roi.pts.length !== 4) return false;
let inside = false;
const pts = roi.pts;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
const xi = pts[i].x,
yi = pts[i].y;
const xj = pts[j].x,
yj = pts[j].y;
const intersect =
yi > ptStreamArr[1] !== yj > ptStreamArr[1] &&
ptStreamArr[0] <
((xj - xi) * (ptStreamArr[1] - yi)) / (yj - yi || CONFIG.roi.epsilon) +
xi;
if (intersect) inside = !inside;
}
return inside;
}
function pointInROI(pt) {
return pointInQuad(pt);
}
function setEditorUI(on) {
STATE.editingROI = on;
if (UI.roiEdit) UI.roiEdit.classList.toggle("hidden", on);
if (UI.roiSave) UI.roiSave.classList.toggle("hidden", !on);
if (UI.roiCancel) UI.roiCancel.classList.toggle("hidden", !on);
if (UI.roiCanvas) UI.roiCanvas.style.pointerEvents = on ? "auto" : "none";
const c = UI.roiCanvas;
if (!c) return;
if (on) {
if (STATE.bedROI && STATE.bedROI.type === "rrect") {
STATE.roiDraftRRect = STATE.bedROI.pts.map((p) => streamToDispPt(p, c));
} else {
STATE.roiDraftRRect = null;
}
} else {
STATE.roiDraftRRect = null;
STATE.roiDragCorner = -1;
STATE.roiLastMouse = null;
drawROIOverlay();
}
}
function drawROIOverlay() {
const c = UI.roiCanvas;
if (!c) return;
const ctx = c.getContext("2d");
ctx.clearRect(0, 0, c.width, c.height);
const drawCorners = (pts) => {
ctx.fillStyle = "rgba(255,0,255,0.10)";
ctx.strokeStyle = "#ff6ad5";
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.closePath();
ctx.fill();
ctx.stroke();
pts.forEach((p) => {
ctx.beginPath();
ctx.arc(p.x, p.y, CONFIG.roi.cornerRadius, 0, Math.PI * 2);
ctx.fillStyle = "#ff6ad5";
ctx.fill();
ctx.strokeStyle = "#fff";
ctx.stroke();
});
};
if (STATE.editingROI) {
if (STATE.roiDraftRRect && STATE.roiDraftRRect.length === 4) {
drawCorners(STATE.roiDraftRRect);
} else if (STATE.roiDraftRRect && STATE.roiDraftRRect.length === 2) {
const [a, b] = STATE.roiDraftRRect;
const rectPts = [
{ x: a.x, y: a.y },
{ x: b.x, y: a.y },
{ x: b.x, y: b.y },
{ x: a.x, y: b.y },
];
drawCorners(rectPts);
}
return;
}
if (
STATE.bedROI &&
STATE.bedROI.type === "rrect" &&
STATE.bedROI.pts.length === 4
) {
const dispPts = STATE.bedROI.pts.map((p) => streamToDispPt(p, c));
drawCorners(dispPts);
}
}
function centroid(pts) {
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length;
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length;
return { x: cx, y: cy };
}
function rotateAll(pts, angleRad) {
const c = centroid(pts);
const cos = Math.cos(angleRad),
sin = Math.sin(angleRad);
return pts.map((p) => {
const dx = p.x - c.x,
dy = p.y - c.y;
return { x: c.x + dx * cos - dy * sin, y: c.y + dx * sin + dy * cos };
});
}
function updateROIStatus() {
if (UI.roiStatusText) {
if (STATE.bedROI && STATE.bedROI.pts && STATE.bedROI.pts.length === 4) {
UI.roiStatusText.textContent = "ROI aktif";
} else {
UI.roiStatusText.textContent = "Tidak ada ROI";
}
}
}
function attachRoiEvents() {
const canvas = UI.roiCanvas;
if (!canvas) return;
let makingRect = false;
const toLocal = (e) => {
const r = canvas.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
canvas.addEventListener("mousedown", (e) => {
if (!STATE.editingROI) return;
const p = toLocal(e);
if (!STATE.roiDraftRRect) {
makingRect = true;
STATE.roiDraftRRect = [p, p];
drawROIOverlay();
return;
}
if (STATE.roiDraftRRect.length === 2) {
makingRect = true;
return;
}
const pts = STATE.roiDraftRRect;
let hit = -1;
for (let i = 0; i < pts.length; i++) {
const d = Math.hypot(pts[i].x - p.x, pts[i].y - p.y);
if (d <= CONFIG.roi.cornerHitDistance) {
hit = i;
break;
}
}
if (hit >= 0) {
STATE.roiDragCorner = hit;
STATE.roiLastMouse = p;
} else {
STATE.roiDragCorner = -1;
STATE.roiLastMouse = p;
}
});
canvas.addEventListener("mousemove", (e) => {
if (!STATE.editingROI) return;
const p = toLocal(e);
if (STATE.roiDraftRRect && STATE.roiDraftRRect.length === 2 && makingRect) {
STATE.roiDraftRRect[1] = p;
drawROIOverlay();
return;
}
if (STATE.roiDraftRRect && STATE.roiDraftRRect.length === 4) {
if (STATE.roiDragCorner >= 0) {
const prev = STATE.roiLastMouse || p;
const dx = p.x - prev.x,
dy = p.y - prev.y;
const pts = STATE.roiDraftRRect.slice();
pts[STATE.roiDragCorner] = {
x: pts[STATE.roiDragCorner].x + dx,
y: pts[STATE.roiDragCorner].y + dy,
};
STATE.roiDraftRRect = pts;
STATE.roiLastMouse = p;
drawROIOverlay();
} else if (STATE.roiLastMouse && e.shiftKey) {
const prev = STATE.roiLastMouse;
const c = centroid(STATE.roiDraftRRect);
const a1 = Math.atan2(prev.y - c.y, prev.x - c.x);
const a2 = Math.atan2(p.y - c.y, p.x - c.x);
const da = a2 - a1;
STATE.roiDraftRRect = rotateAll(STATE.roiDraftRRect, da);
STATE.roiLastMouse = p;
drawROIOverlay();
}
}
});
window.addEventListener("mouseup", () => {
makingRect = false;
if (STATE.roiDraftRRect && STATE.roiDraftRRect.length === 2) {
const [a, b] = STATE.roiDraftRRect;
const rectPts = [
{ x: a.x, y: a.y },
{ x: b.x, y: a.y },
{ x: b.x, y: b.y },
{ x: a.x, y: b.y },
];
STATE.roiDraftRRect = rectPts;
drawROIOverlay();
}
STATE.roiDragCorner = -1;
STATE.roiLastMouse = null;
});
canvas.addEventListener("contextmenu", (e) => {
if (STATE.editingROI) {
e.preventDefault();
STATE.roiDragCorner = -1;
return false;
}
});
if (UI.roiEdit) {
UI.roiEdit.addEventListener("click", () => setEditorUI(true));
}
if (UI.roiCancel) {
UI.roiCancel.addEventListener("click", () => setEditorUI(false));
}
if (UI.roiSave) {
UI.roiSave.addEventListener("click", () => {
if (!STATE.roiDraftRRect || STATE.roiDraftRRect.length !== 4) {
alert("Buat rectangle dulu (drag) hingga muncul 4 titik sudut.");
return;
}
const canvas = UI.roiCanvas;
const ptsStream = STATE.roiDraftRRect.map((p) =>
dispToStreamPt(p, canvas)
);
STATE.bedROI = { type: "rrect", pts: ptsStream };
saveROI(STATE.bedROI);
setEditorUI(false);
drawROIOverlay();
updateROIStatus();
alert("ROI (rotated rectangle) disimpan.");
});
}
if (UI.roiDelete) {
UI.roiDelete.addEventListener("click", deleteROI);
}
}
// ========== POSE INDICES ==========
const MP_INDEX = {
NOSE: 0,
LEFT_SHOULDER: 11,
RIGHT_SHOULDER: 12,
LEFT_ELBOW: 13,
RIGHT_ELBOW: 14,
LEFT_WRIST: 15,
RIGHT_WRIST: 16,
LEFT_HIP: 23,
RIGHT_HIP: 24,
LEFT_KNEE: 25,
RIGHT_KNEE: 26,
LEFT_ANKLE: 27,
RIGHT_ANKLE: 28,
};
function getPts(landmarks, W, H) {
if (!landmarks || landmarks.length < 33) return {};
const get = (i) => {
const p = landmarks[i];
return [Math.round(p.x * W), Math.round(p.y * H), p.visibility ?? 1.0];
};
return {
nose: get(MP_INDEX.NOSE),
left_shoulder: get(MP_INDEX.LEFT_SHOULDER),
right_shoulder: get(MP_INDEX.RIGHT_SHOULDER),
left_elbow: get(MP_INDEX.LEFT_ELBOW),
right_elbow: get(MP_INDEX.RIGHT_ELBOW),
left_wrist: get(MP_INDEX.LEFT_WRIST),
right_wrist: get(MP_INDEX.RIGHT_WRIST),
left_hip: get(MP_INDEX.LEFT_HIP),
right_hip: get(MP_INDEX.RIGHT_HIP),
left_knee: get(MP_INDEX.LEFT_KNEE),
right_knee: get(MP_INDEX.RIGHT_KNEE),
left_ankle: get(MP_INDEX.LEFT_ANKLE),
right_ankle: get(MP_INDEX.RIGHT_ANKLE),
};
}
function getPoint(lms, i) {
if (!lms || !lms[i]) return null;
return { x: lms[i].x, y: lms[i].y, visibility: lms[i].visibility ?? 1 };
}
function torsoAngleDeg(shoulders_mid, hips_mid) {
if (!shoulders_mid || !hips_mid) return 0;
const vx = shoulders_mid[0] - hips_mid[0];
const vy = shoulders_mid[1] - hips_mid[1];
const mag = Math.hypot(vx, vy);
if (mag === 0) return 0;
const cos_v = vy / mag;
const angle = (Math.acos(clamp(cos_v, -1, 1)) * 180) / Math.PI;
return angle;
}
function computeAngles(lm) {
const p = (n) => (lm[n] ? [lm[n][0], lm[n][1]] : null);
return {
left_elbow: angleBetween(
p("left_shoulder"),
p("left_elbow"),
p("left_wrist")
),
right_elbow: angleBetween(
p("right_shoulder"),
p("right_elbow"),
p("right_wrist")
),
left_shoulder: angleBetween(
p("left_hip"),
p("left_shoulder"),
p("left_elbow")
),
right_shoulder: angleBetween(
p("right_hip"),
p("right_shoulder"),
p("right_elbow")
),
left_hip: angleBetween(p("left_shoulder"), p("left_hip"), p("left_knee")),
right_hip: angleBetween(
p("right_shoulder"),
p("right_hip"),
p("right_knee")
),
left_knee: angleBetween(p("left_hip"), p("left_knee"), p("left_ankle")),
right_knee: angleBetween(p("right_hip"), p("right_knee"), p("right_ankle")),
};
}
// ========== UI HELPERS ==========
function showToast(text = "ALERT!") {
UI.toast.textContent = text;
UI.toast.classList.remove("hidden");
requestAnimationFrame(() => UI.toast.classList.add("show"));
try {
UI.audio.currentTime = 0;
UI.audio.play().catch(() => {});
} catch {}
setTimeout(() => {
UI.toast.classList.remove("show");
setTimeout(() => UI.toast.classList.add("hidden"), 250);
}, 3000);
}
function setStatusText(text) {
UI.statusText.textContent = text;
}
function showLoading(show) {
UI.loadingOverlay.classList.toggle("hidden", !show);
}
function updateFeatureToggles() {
const cameraOn = STATE.cameraActive;
// Enable/disable feature toggles based on camera state
UI.fallToggleItem.classList.toggle("disabled", !cameraOn);
UI.rehabToggleItem.classList.toggle("disabled", !cameraOn);
if (!cameraOn) {
UI.toggleFall.checked = false;
UI.toggleRehab.checked = false;
STATE.fallDetectionActive = false;
STATE.rehabActive = false;
}
// Show/hide info panels
UI.fallInfoPanel.classList.toggle("hidden", !STATE.fallDetectionActive);
UI.rehabInfoPanel.classList.toggle("hidden", !STATE.rehabActive);
UI.anglesPanel.classList.toggle(
"hidden",
!STATE.fallDetectionActive && !STATE.rehabActive
);
// Show/hide ROI panel when fall detection is active
if (UI.roiPanel) {
UI.roiPanel.classList.toggle("hidden", !STATE.fallDetectionActive);
}
// Show/hide History panel when rehab is active
updateHistoryPanelVisibility();
}
function updateFPS() {
const now = performance.now();
const dt = now - STATE.lastFrameT;
STATE.lastFrameT = now;
const fps = dt > 0 ? 1000 / dt : 0;
STATE.fpsHistory.push(fps);
if (STATE.fpsHistory.length > 30) STATE.fpsHistory.shift();
const avgFps =
STATE.fpsHistory.length > 0
? STATE.fpsHistory.reduce((a, b) => a + b, 0) / STATE.fpsHistory.length
: 0;
UI.fpsDisplay.textContent = avgFps.toFixed(1);
}
// ========== AUTHENTICATION & USER MANAGEMENT ==========
async function initializeAuth() {
// Check if user explicitly chose guest mode
const isGuestMode = localStorage.getItem("guestMode") === "true";
if (isGuestMode) {
// User is in guest mode
STATE.auth.isGuest = true;
STATE.auth.isAuthenticated = false;
STATE.auth.user = null;
console.log("[Auth] 👤 Guest mode active");
updateUserProfileUI();
return;
}
// Try to load Firebase
const firebaseService = await loadFirebase();
if (!firebaseService) {
// Firebase not available, allow access without auth
console.log("[Auth] ⚠️ Firebase not available");
STATE.auth.isGuest = false;
STATE.auth.isAuthenticated = false;
STATE.auth.user = null;
updateUserProfileUI();
return;
}
// Initialize Firebase auth
const authState = await firebaseService.initAuth();
STATE.auth.user = authState.user;
STATE.auth.isGuest = authState.isGuest;
STATE.auth.isAuthenticated = authState.isAuthenticated;
console.log("[Auth] Firebase initialized:", {
hasUser: !!STATE.auth.user,
isGuest: STATE.auth.isGuest,
isAuthenticated: STATE.auth.isAuthenticated,
});
updateUserProfileUI();
// Listen for auth changes
firebaseService.onAuthChange((state) => {
STATE.auth.user = state.user;
STATE.auth.isGuest = state.isGuest;
STATE.auth.isAuthenticated = state.isAuthenticated;
updateUserProfileUI();
});
}
async function updateUserProfileUI() {
if (UI.userProfile && UI.guestBadge) {
if (STATE.auth.user) {
// Logged in user
UI.userProfile.classList.remove("hidden");
UI.guestBadge.classList.add("hidden");
if (UI.userAvatar && STATE.auth.user.photoURL) {
UI.userAvatar.src = STATE.auth.user.photoURL;
} else if (UI.userAvatar) {
// Default avatar for email users
UI.userAvatar.src =
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%232d8cff'%3E%3Cpath d='M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08-1.29 1.94-3.5 3.22-6 3.22z'/%3E%3C/svg%3E";
}
// Try to get username from Firestore profile
try {
const firebaseService = await loadFirebase();
if (firebaseService && firebaseService.getUserProfile) {
const profileResult = await firebaseService.getUserProfile(
STATE.auth.user.uid
);
if (profileResult.success && profileResult.profile) {
// Display username (prefer displayUsername for original case, fallback to username)
const displayName =
profileResult.profile.displayUsername ||
profileResult.profile.username;
if (UI.userName && displayName) {
UI.userName.textContent = displayName;
} else if (UI.userName) {
// Fallback to Google displayName or email
UI.userName.textContent =
STATE.auth.user.displayName || STATE.auth.user.email || "User";
}
} else if (UI.userName) {
// No profile found, use Google info
UI.userName.textContent =
STATE.auth.user.displayName || STATE.auth.user.email || "User";
}
} else if (UI.userName) {
// Firebase not available
UI.userName.textContent =
STATE.auth.user.displayName || STATE.auth.user.email || "User";
}
} catch (error) {
console.error("Error fetching user profile:", error);
if (UI.userName) {
UI.userName.textContent =
STATE.auth.user.displayName || STATE.auth.user.email || "User";
}
}
} else if (STATE.auth.isGuest) {
// Guest mode
UI.userProfile.classList.add("hidden");
UI.guestBadge.classList.remove("hidden");
} else {
// Not authenticated
UI.userProfile.classList.add("hidden");
UI.guestBadge.classList.add("hidden");
}
}
// Update storage indicator
updateStorageIndicator();
}
function updateStorageIndicator() {
const indicator = UI.storageIndicatorActive;
if (!indicator) return;
if (STATE.auth.user) {
indicator.className = "storage-indicator firebase";
indicator.innerHTML =
'<span class="storage-indicator-icon">☁️</span><span>Data disimpan ke Firebase</span>';
} else if (STATE.auth.isGuest) {
indicator.className = "storage-indicator guest";
indicator.innerHTML =
'<span class="storage-indicator-icon">⚠️</span><span>Data disimpan sementara (mode tamu)</span>';
}
}
async function handleLogout() {
// Clear any pending Bardi reset timer
if (typeof window.clearBardiResetTimer === "function") {
window.clearBardiResetTimer();
}
// Reset Bardi status before logout
localStorage.removeItem("lastBardiTriggerStatus");
updateStatusIndicators(); // Will show "STANDBY"
const firebase = await loadFirebase();
if (firebase) {
await firebase.logOut();
}
localStorage.removeItem("guestMode");
window.location.href = "login.html";
}
// ========== STATUS INDICATORS ==========
function updateStatusIndicators() {
// Bardi status - based on last trigger result
if (UI.bardiStatus) {
const bardiConfigured = !!window.TUYA_DEVICE_ID;
if (!bardiConfigured) {
UI.bardiStatus.textContent = "NOT SET";
UI.bardiStatus.className = "indicator-status off";
STATE.connections.bardiConnected = false;
} else {
const lastTrigger = localStorage.getItem("lastBardiTriggerStatus");
if (lastTrigger === "success") {
UI.bardiStatus.textContent = "CONNECTED";
UI.bardiStatus.className = "indicator-status on";
STATE.connections.bardiConnected = true;
} else if (lastTrigger === "failed") {
UI.bardiStatus.textContent = "FAILED";
UI.bardiStatus.className = "indicator-status off";
STATE.connections.bardiConnected = false;
} else {
UI.bardiStatus.textContent = "STANDBY";
UI.bardiStatus.className = "indicator-status standby";
STATE.connections.bardiConnected = false;
}
}
}
// Telegram status (keep existing logic)
const telegramConnected =
TELEGRAM.enabled && (TELEGRAM.proxyUrl || TELEGRAM.botToken);
STATE.connections.telegramConnected = telegramConnected;
if (UI.telegramStatus) {
UI.telegramStatus.textContent = telegramConnected ? "ON" : "OFF";
UI.telegramStatus.className = `indicator-status ${
telegramConnected ? "on" : "off"
}`;
}
}
// Test Telegram connection
async function testTelegramConnection() {
try {
// Simple check - we'll mark it as connected if config is present
const connected =
TELEGRAM.enabled && (TELEGRAM.proxyUrl || TELEGRAM.botToken);
STATE.connections.telegramConnected = connected;
updateStatusIndicators();
return connected;
} catch {
STATE.connections.telegramConnected = false;
updateStatusIndicators();
return false;
}
}
// ========== CALIBRATION SYSTEM ==========
function showCalibrationModal(pendingAction) {
STATE.calibration.pendingAction = pendingAction;
STATE.calibration.isCalibrated = false;
STATE.calibration.poseDetected = false;
if (UI.calibrationModal) {
UI.calibrationModal.classList.remove("hidden");
}
updateCalibrationStatus();
}
function hideCalibrationModal() {
if (UI.calibrationModal) {
UI.calibrationModal.classList.add("hidden");
}
// Note: pendingAction is cleared by the caller (confirmCalibration/skipCalibration)
// after they use it to determine which feature to enable
}
function updateCalibrationStatus() {
if (
!UI.calibrationStatus ||
!UI.calibrationStatusIcon ||
!UI.calibrationStatusText ||
!UI.calibrationConfirmBtn
) {
return;
}
if (STATE.calibration.allLandmarksVisible) {
UI.calibrationStatus.classList.remove("detecting");
UI.calibrationStatus.classList.add("ready");
UI.calibrationStatusIcon.textContent = "✅";
UI.calibrationStatusText.textContent = "Pose terdeteksi dengan baik!";
UI.calibrationConfirmBtn.disabled = false;
} else if (STATE.calibration.poseDetected) {
UI.calibrationStatus.classList.add("detecting");
UI.calibrationStatus.classList.remove("ready");
UI.calibrationStatusIcon.textContent = "⚠️";
UI.calibrationStatusText.textContent =
"Pose terdeteksi, tapi beberapa titik tidak terlihat";
UI.calibrationConfirmBtn.disabled = true;
} else {
UI.calibrationStatus.classList.add("detecting");
UI.calibrationStatus.classList.remove("ready");
UI.calibrationStatusIcon.textContent = "⏳";
UI.calibrationStatusText.textContent = "Mendeteksi pose...";
UI.calibrationConfirmBtn.disabled = true;
}
}
function checkPoseCalibration(landmarks) {
if (!landmarks || landmarks.length < 33) {
STATE.calibration.poseDetected = false;
STATE.calibration.allLandmarksVisible = false;
return;
}
STATE.calibration.poseDetected = true;
// Check if key landmarks are visible
const keyLandmarks = [
0, // nose
11,
12, // shoulders
13,
14, // elbows
15,
16, // wrists
23,
24, // hips
25,
26, // knees
27,
28, // ankles
];
const visibilityThreshold = 0.5;
const allVisible = keyLandmarks.every((idx) => {
const lm = landmarks[idx];
return lm && (lm.visibility || 0) > visibilityThreshold;
});
STATE.calibration.allLandmarksVisible = allVisible;
// Update calibration UI
if (
UI.calibrationModal &&
!UI.calibrationModal.classList.contains("hidden")
) {
updateCalibrationStatus();
}
}
function confirmCalibration() {
STATE.calibration.isCalibrated = true;
hideCalibrationModal();
// Enable the pending action
if (STATE.calibration.pendingAction === "fall") {
STATE.fallDetectionActive = true;
UI.toggleFall.checked = true;
updateFeatureToggles();
setStatusText("Fall Detection aktif");
} else if (STATE.calibration.pendingAction === "rehab") {
STATE.rehabActive = true;
UI.toggleRehab.checked = true;
updateFeatureToggles();
const exercise = EXERCISES[STATE.rehab.currentExercise];
setStatusText(
`Rehab Medic aktif - ${exercise ? exercise.name : "Unknown"}`
);
// Load history when rehab is enabled
loadRehabHistory(false);
}
STATE.calibration.pendingAction = null;
}
function skipCalibration() {
hideCalibrationModal();
// Enable the pending action anyway
if (STATE.calibration.pendingAction === "fall") {
STATE.fallDetectionActive = true;
UI.toggleFall.checked = true;
updateFeatureToggles();
setStatusText("Fall Detection aktif (tanpa kalibrasi)");
} else if (STATE.calibration.pendingAction === "rehab") {
STATE.rehabActive = true;
UI.toggleRehab.checked = true;
updateFeatureToggles();
const exercise = EXERCISES[STATE.rehab.currentExercise];
setStatusText(
`Rehab Medic aktif - ${exercise ? exercise.name : "Unknown"}`
);
// Load history when rehab is enabled
loadRehabHistory(false);
}
STATE.calibration.pendingAction = null;
}
// ========== SAVE REHAB HISTORY ==========
async function saveRehabHistory(rehabData) {
const firebaseService = await loadFirebase();
if (firebaseService && STATE.auth.user) {
// Save to Firebase
const result = await firebaseService.saveRehabHistory(rehabData);
if (result.success) {
showToast("✅ Data disimpan ke cloud");
} else {
showToast("❌ Gagal menyimpan ke cloud");
}
return result;
} else if (STATE.auth.isGuest) {
// Save to localStorage
try {
const existingHistory = JSON.parse(
localStorage.getItem("guestRehabHistory") || "[]"
);
existingHistory.push({
...rehabData,
timestamp: new Date().toISOString(),
});
localStorage.setItem(
"guestRehabHistory",
JSON.stringify(existingHistory)
);
showToast("💾 Data disimpan sementara");
return { success: true };
} catch (error) {
console.error("Error saving to localStorage:", error);
return { success: false, error: error.message };
}
}
return { success: false, error: "Not authenticated" };
}
// ========== ENLARGED STATS UPDATE ==========
function updateEnlargedRehabStats(
leftReps,
rightReps,
targetLeft,
targetRight
) {
if (UI.rehabBigLeft) {
UI.rehabBigLeft.textContent = leftReps;
}
if (UI.rehabBigRight) {
UI.rehabBigRight.textContent = rightReps;
}
if (UI.rehabTargetLeftBig) {
UI.rehabTargetLeftBig.textContent = targetLeft;
}
if (UI.rehabTargetRightBig) {
UI.rehabTargetRightBig.textContent = targetRight;
}
}
// ========== FALL DETECTION LOGIC ==========
function detectWaving(t, lm) {
const ls = lm.left_shoulder,
rs = lm.right_shoulder;
const lw = lm.left_wrist,
rw = lm.right_wrist;
const shoulders_mid = mid(lm.left_shoulder, lm.right_shoulder);
const hips_mid = mid(lm.left_hip, lm.right_hip);
if (!ls || !rs || !lw || !rw || !shoulders_mid || !hips_mid) {
return false;
}
const shoulderW = Math.max(1, dist(ls, rs));
const torsoH = Math.max(1, dist(shoulders_mid, hips_mid));
const minHandY =
shoulders_mid[1] - CONFIG.fall.waving.handRaisedMinY * torsoH;
const leftRaised = lw[1] < minHandY;
const rightRaised = rw[1] < minHandY;
if (!leftRaised && !rightRaised) {
STATE.fall.wristHistory = [];
STATE.fall.swingCount = 0;
STATE.fall.lastSwingDir = null;
return false;
}
const activeWrist = leftRaised ? lw : rw;
STATE.fall.wristHistory.push({
t: t,
x: activeWrist[0],
y: activeWrist[1],
});
const cutoffTime = t - CONFIG.fall.waving.timeWindow;
STATE.fall.wristHistory = STATE.fall.wristHistory.filter(
(h) => h.t >= cutoffTime
);
if (STATE.fall.wristHistory.length < 3) {
return false;
}
const swingThresholdPx = shoulderW * CONFIG.fall.waving.swingThreshold;
const hist = STATE.fall.wristHistory;
for (let i = 1; i < hist.length; i++) {
const prev = hist[i - 1];
const curr = hist[i];
const dx = curr.x - prev.x;
if (Math.abs(dx) > swingThresholdPx) {
const currentDir = dx > 0 ? "right" : "left";
if (STATE.fall.lastSwingDir && STATE.fall.lastSwingDir !== currentDir) {
STATE.fall.swingCount++;
}
STATE.fall.lastSwingDir = currentDir;
}
}
if (STATE.fall.wristHistory.length > 0) {
const oldestTime = STATE.fall.wristHistory[0].t;
if (t - oldestTime >= CONFIG.fall.waving.timeWindow) {
STATE.fall.swingCount = 0;
}
}
return STATE.fall.swingCount >= CONFIG.fall.waving.minSwings;
}
function updateFallDetection(t, lmStream) {
const lm = lmStream;
const shoulders_mid = mid(lm.left_shoulder, lm.right_shoulder);
const hips_mid = mid(lm.left_hip, lm.right_hip);
const torso_mid =
shoulders_mid && hips_mid
? mid(shoulders_mid, hips_mid)
: hips_mid || shoulders_mid;
const angles = computeAngles(lm);
// Speed center
let speed = 0;
if (torso_mid) {
const last = STATE.fall.centerHist.length
? STATE.fall.centerHist[STATE.fall.centerHist.length - 1]
: null;
if (last) {
const dt = Math.max(1e-3, t - last[0]);
speed = Math.hypot(torso_mid[0] - last[1], torso_mid[1] - last[2]) / dt;
}
STATE.fall.centerHist.push([t, torso_mid[0], torso_mid[1]]);
if (STATE.fall.centerHist.length > 90) STATE.fall.centerHist.shift();
}
const speedSmooth = STATE.fall.speedEMA(speed);
const torsoAngle = torsoAngleDeg(shoulders_mid, hips_mid);
const horizontal = torsoAngle >= CONFIG.fall.horizontalAngleDeg;
const ground = !!(
hips_mid && hips_mid[1] >= CONFIG.streamH * CONFIG.fall.groundYRatio
);
const sudden = speedSmooth >= CONFIG.fall.suddenSpeedThresh;
if (sudden) STATE.fall.lastSuddenT = t;
let inactive = false;
if (
STATE.fall.lastSuddenT &&
t - STATE.fall.lastSuddenT <= CONFIG.fall.inactivityWindowS
) {
inactive = speedSmooth <= CONFIG.fall.inactivitySpeedThresh;
}
const waving = detectWaving(t, lm);
if (waving) {
if (!STATE.fall.wavingNow) {
STATE.fall.wavingSince = t;
STATE.fall.wavingNow = true;
}
STATE.fall.lastWavingT = t;
} else {
STATE.fall.wavingNow = false;
}
// Confidence (fall)
let conf = 0;
conf += horizontal ? 0.35 : 0;
conf += ground ? 0.25 : 0;
conf += sudden ? 0.25 : 0;
conf += inactive ? 0.15 : 0;
// Bed ROI gating (sleeping detection)
const ref = torso_mid || hips_mid;
const sleeping = !!(horizontal && ref && pointInROI(ref));
if (sleeping) conf = CONFIG.fall.sleepingConfidence;
let safe = conf < CONFIG.fall.confThreshold || sleeping;
if (!safe && !sleeping) {
if (!STATE.fall.inFallWindow) {
STATE.fall.inFallWindow = true;
STATE.fall.lastFallTriggerT = t;
}
} else {
if (STATE.fall.inFallWindow) STATE.fall.inFallWindow = false;
}
let timer = 0;
if (STATE.fall.inFallWindow && STATE.fall.lastFallTriggerT) {
timer = t - STATE.fall.lastFallTriggerT;
}
// HELP trigger
const sustainedWaving =
STATE.fall.wavingNow &&
t - STATE.fall.wavingSince >= CONFIG.fall.help.sustainS;
if (sustainedWaving) {
if (!STATE.fall.helpActive) {
STATE.fall.helpActive = true;
STATE.fall.helpSince = t;
}
STATE.fall.helpExpiresAt = t + CONFIG.fall.help.holdS;
} else if (STATE.fall.helpActive) {
const quiet = t - (STATE.fall.lastWavingT || 0);
if (
t >= STATE.fall.helpExpiresAt &&
quiet >= CONFIG.fall.help.clearAfterQuietS
) {
STATE.fall.helpActive = false;
STATE.fall.swingCount = 0;
STATE.fall.lastSwingDir = null;
}
}
return {
angles,
fall_confidence: conf,
safe,
sleeping,
timer,
help_active: STATE.fall.helpActive,
waving: STATE.fall.wavingNow,
};
}
// ========== REHAB MEDIC LOGIC ==========
// Helper function to get joint points from raw landmarks
function getJointPoints(lmRaw, jointConfig) {
const a = getPoint(lmRaw, MP_INDEX[jointConfig.a]);
const b = getPoint(lmRaw, MP_INDEX[jointConfig.b]);
const c = getPoint(lmRaw, MP_INDEX[jointConfig.c]);
return { a, b, c };
}
// Calculate form error based on current angle and ideal angle
function calculateFormError(currentAngle, stage, exercise) {
if (currentAngle == null || stage == null) return null;
// Get the ideal angle based on stage - each exercise has its own ideal values defined in config
const idealAngle =
stage === "down"
? exercise.idealDownAngle
: stage === "up"
? exercise.idealUpAngle
: null;
if (idealAngle == null) return null;
return Math.abs(currentAngle - idealAngle);
}
// Update form accuracy metrics
function updateFormAccuracy(errorLeft, errorRight, exercise) {
const errors = [];
if (errorLeft != null) errors.push(errorLeft);
if (errorRight != null && exercise.trackBothSides) errors.push(errorRight);
if (errors.length === 0) return;
const avgError = errors.reduce((a, b) => a + b, 0) / errors.length;
// Add to history (sliding window of last 100 samples)
STATE.rehab.errorHistory.push(avgError);
// Keep only last 100 samples for real-time mean
if (STATE.rehab.errorHistory.length > 100) {
STATE.rehab.errorHistory.shift();
}
// Calculate mean error from all samples in history
const historySum = STATE.rehab.errorHistory.reduce((a, b) => a + b, 0);
STATE.rehab.currentMeanError = historySum / STATE.rehab.errorHistory.length;
// Calculate accuracy percentage (inverse of error)
// 0° error = 100%, maxAcceptableError = 0%
const maxError = CONFIG.formAccuracy.maxAcceptableError;
const accuracy = Math.max(
0,
Math.min(100, 100 - (STATE.rehab.currentMeanError / maxError) * 100)
);
STATE.rehab.currentFormAccuracy = accuracy;
}
// Helper function to compare angle with threshold based on comparison operator
function compareAngle(angle, threshold, operator) {
switch (operator) {
case ">":
return angle > threshold;
case "<":
return angle < threshold;
case ">=":
return angle >= threshold;
case "<=":
return angle <= threshold;
default:
return angle > threshold; // Default to greater-than
}
}
// Process one side (left or right) of an exercise
function processExerciseSide(lmRaw, exercise, side) {
const joints = getJointPoints(lmRaw, exercise.joints[side]);
if (!joints.a || !joints.b || !joints.c) {
return { angle: null, error: null };
}
const rawAngleKey = side === "left" ? "rawAngleLeft" : "rawAngleRight";
const smoothAngleKey =
side === "left" ? "smoothAngleLeft" : "smoothAngleRight";
const stageKey = side === "left" ? "stageLeft" : "stageRight";
const repsKey = side === "left" ? "repsLeft" : "repsRight";
// Calculate angle
STATE.rehab[rawAngleKey] = angleBetweenObj(joints.a, joints.b, joints.c);
STATE.rehab[smoothAngleKey] = ema(
STATE.rehab[smoothAngleKey],
STATE.rehab[rawAngleKey],
CONFIG.rehab.angleAlpha
);
// Update stage based on configuration-driven comparison
const currentAngle = STATE.rehab[rawAngleKey];
// Check if entering "down" stage
if (
compareAngle(currentAngle, exercise.downThreshold, exercise.downCompare)
) {
STATE.rehab[stageKey] = "down";
}
// Check if transitioning to "up" stage (rep counted)
if (
compareAngle(currentAngle, exercise.upThreshold, exercise.upCompare) &&
STATE.rehab[stageKey] === "down"
) {
STATE.rehab[stageKey] = "up";
STATE.rehab[repsKey]++;
}
// Calculate form error
const error = calculateFormError(
currentAngle,
STATE.rehab[stageKey],
exercise
);
return { angle: STATE.rehab[smoothAngleKey], error };
}
// Special processing for Sit-to-Stand exercise
// Uses two angles: knee angle and hip angle
function processSitToStand(lmRaw, exercise) {
// Calculate knee angle (hip-knee-ankle)
const hipL = getPoint(lmRaw, MP_INDEX.LEFT_HIP);
const kneeL = getPoint(lmRaw, MP_INDEX.LEFT_KNEE);
const ankleL = getPoint(lmRaw, MP_INDEX.LEFT_ANKLE);
// Calculate hip angle (shoulder-hip-knee)
const shoulderL = getPoint(lmRaw, MP_INDEX.LEFT_SHOULDER);
let kneeAngle = null;
let hipAngle = null;
if (hipL && kneeL && ankleL) {
kneeAngle = angleBetweenObj(hipL, kneeL, ankleL);
}
if (shoulderL && hipL && kneeL) {
hipAngle = angleBetweenObj(shoulderL, hipL, kneeL);
}
// Smooth angles
if (kneeAngle !== null) {
STATE.rehab.smoothAngleLeft = ema(
STATE.rehab.smoothAngleLeft,
kneeAngle,
CONFIG.rehab.angleAlpha
);
}
if (hipAngle !== null) {
STATE.rehab.smoothAngleRight = ema(
STATE.rehab.smoothAngleRight,
hipAngle,
CONFIG.rehab.angleAlpha
);
}
// State machine: both angles must be in correct range
// Sitting: both angles < 100°
// Standing: knee >= 155° AND hip >= 140°
const kneeSmooth = STATE.rehab.smoothAngleLeft;
const hipSmooth = STATE.rehab.smoothAngleRight;
if (kneeSmooth !== null && hipSmooth !== null) {
const bothFlexed =
kneeSmooth < exercise.upThreshold && hipSmooth < exercise.upThreshold;
const bothExtended =
kneeSmooth >= exercise.downThreshold && hipSmooth >= 140;
if (bothFlexed) {
STATE.rehab.stageLeft = "sitting";
}
if (bothExtended && STATE.rehab.stageLeft === "sitting") {
STATE.rehab.stageLeft = "standing";
STATE.rehab.repsLeft++;
STATE.rehab.repsRight = STATE.rehab.repsLeft; // Mirror for display
}
STATE.rehab.stageRight = STATE.rehab.stageLeft;
}
return {
angleLeft: STATE.rehab.smoothAngleLeft,
angleRight: STATE.rehab.smoothAngleRight,
error: null, // No form accuracy for this exercise
};
}
// Main rehab exercise update function
function updateRehabMedic(lmRaw) {
const exerciseKey = STATE.rehab.currentExercise;
const exercise = EXERCISES[exerciseKey];
if (!exercise) {
return {
repsLeft: 0,
repsRight: 0,
repsCombined: 0,
stageLeft: null,
stageRight: null,
stageCombined: null,
angleLeft: null,
angleRight: null,
formAccuracy: null,
meanError: null,
exerciseName: "Unknown",
};
}
let leftResult, rightResult;
// Special handling for sit-to-stand exercise
if (exercise.specialLogic === "sit_to_stand") {
const sitToStandResult = processSitToStand(lmRaw, exercise);
leftResult = {
angle: sitToStandResult.angleLeft,
error: sitToStandResult.error,
};
rightResult = {
angle: sitToStandResult.angleRight,
error: sitToStandResult.error,
};
} else {
// Process both sides using the standard helper function
leftResult = processExerciseSide(lmRaw, exercise, "left");
rightResult = processExerciseSide(lmRaw, exercise, "right");
}
// Update form accuracy (skip for sit-to-stand)
if (exercise.specialLogic !== "sit_to_stand") {
updateFormAccuracy(leftResult.error, rightResult.error, exercise);
}
// For exercises that don't track both sides separately,
// use combined counter (whichever detected more)
if (!exercise.trackBothSides) {
STATE.rehab.repsCombined = Math.max(
STATE.rehab.repsLeft,
STATE.rehab.repsRight
);
STATE.rehab.stageCombined = STATE.rehab.stageLeft || STATE.rehab.stageRight;
}
return {
repsLeft: STATE.rehab.repsLeft,
repsRight: STATE.rehab.repsRight,
repsCombined: STATE.rehab.repsCombined,
stageLeft: STATE.rehab.stageLeft,
stageRight: STATE.rehab.stageRight,
stageCombined: STATE.rehab.stageCombined,
angleLeft: STATE.rehab.smoothAngleLeft,
angleRight: STATE.rehab.smoothAngleRight,
formAccuracy: STATE.rehab.currentFormAccuracy,
meanError: STATE.rehab.currentMeanError,
exerciseName: exercise.name,
trackBothSides: exercise.trackBothSides,
labelLeft: exercise.labelLeft,
labelRight: exercise.labelRight,
};
}
function resetRehabCounter() {
STATE.rehab.repsLeft = 0;
STATE.rehab.repsRight = 0;
STATE.rehab.repsCombined = 0;
STATE.rehab.stageLeft = null;
STATE.rehab.stageRight = null;
STATE.rehab.stageCombined = null;
STATE.rehab.rawAngleLeft = null;
STATE.rehab.rawAngleRight = null;
STATE.rehab.smoothAngleLeft = null;
STATE.rehab.smoothAngleRight = null;
// Reset form accuracy
STATE.rehab.errorHistory = [];
STATE.rehab.currentFormAccuracy = null;
STATE.rehab.currentMeanError = null;
const exercise = EXERCISES[STATE.rehab.currentExercise];
updateRehabUI({
repsLeft: 0,
repsRight: 0,
repsCombined: 0,
stageLeft: null,
stageRight: null,
stageCombined: null,
angleLeft: null,
angleRight: null,
formAccuracy: null,
meanError: null,
exerciseName: exercise ? exercise.name : "Unknown",
trackBothSides: exercise ? exercise.trackBothSides : true,
labelLeft: exercise ? exercise.labelLeft : "Kiri",
labelRight: exercise ? exercise.labelRight : "Kanan",
});
}
// Switch to a different exercise
function switchExercise(exerciseKey) {
// Handle rehab_mode selection specially
if (exerciseKey === "rehab_mode") {
showRehabModeUI("setup");
return;
}
// Hide rehab mode UI and show standard exercise panel
showRehabModeUI("standard");
if (!EXERCISES[exerciseKey]) return;
STATE.rehab.currentExercise = exerciseKey;
resetRehabCounter();
const exercise = EXERCISES[exerciseKey];
// Update UI labels
if (UI.exerciseTitle) {
UI.exerciseTitle.textContent = exercise.name;
}
if (UI.statLabelLeft) {
UI.statLabelLeft.textContent = exercise.labelLeft;
}
if (UI.statLabelRight) {
UI.statLabelRight.textContent = exercise.labelRight;
}
}
// ========== TELEGRAM HELPERS ==========
async function sendTelegram(text) {
if (!TELEGRAM.enabled) return false;
if (!text || !text.trim()) return false; // Validate text is not empty
// No chatId validation - broadcast mode to all subscribers
try {
// Only proxy mode is supported in broadcast setup
if (!TELEGRAM.proxyUrl) return false;
const resp = await fetch(TELEGRAM.proxyUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }), // Only send text, broadcast to all subscribers
});
return resp.ok;
} catch {
return false;
}
}
async function maybeSendTelegramHelp() {
if (!TELEGRAM.enabled) return;
const now = nowS();
if (now - (STATE.fall.lastHelpSent || 0) < (TELEGRAM.cooldownS || 60)) return;
const ts = new Date().toLocaleString();
const text = [
"🟠 HELP: Waving gesture detected (melambaikan tangan)",
`Time: ${ts}`,
].join("\n");
try {
const sent = await sendTelegram(text);
if (sent) {
STATE.fall.lastHelpSent = now;
if (
typeof window !== "undefined" &&
typeof window.onDetectedAndNotified === "function"
) {
window
.onDetectedAndNotified("help")
.catch((e) => console.error("onDetectedAndNotified(help) error", e));
}
}
} catch (e) {
console.error("maybeSendTelegramHelp error:", e);
}
}
async function maybeSendTelegramFall(confVal) {
if (!TELEGRAM.enabled) return;
const now = nowS();
if (now - (STATE.fall.lastFallSent || 0) < (TELEGRAM.cooldownS || 60)) return;
const ts = new Date().toLocaleString();
const text = [
"🚨 EMERGENCY: FALL DETECTED",
`Time: ${ts}`,
`Fall Confidence: ${Math.round((confVal || 0) * 100)}%`,
].join("\n");
try {
const sent = await sendTelegram(text);
if (sent) {
STATE.fall.lastFallSent = now;
if (
typeof window !== "undefined" &&
typeof window.onDetectedAndNotified === "function"
) {
window
.onDetectedAndNotified("fall", confVal)
.catch((e) => console.error("onDetectedAndNotified(fall) error", e));
}
}
} catch (e) {
console.error("maybeSendTelegramFall error:", e);
}
}
// ========== DRAWING FUNCTIONS ==========
function drawSkeleton(ctx, lm, W, H) {
const line = (a, b) => {
if (a && b) {
ctx.beginPath();
ctx.moveTo(a[0], a[1]);
ctx.lineTo(b[0], b[1]);
ctx.stroke();
}
};
const circ = (p) => {
if (p) {
ctx.beginPath();
ctx.arc(p[0], p[1], 4, 0, Math.PI * 2);
ctx.fill();
}
};
ctx.lineWidth = 3;
ctx.strokeStyle = "#00ff7f";
ctx.fillStyle = "#33aaff";
line(lm.left_shoulder, lm.right_shoulder);
line(lm.left_shoulder, lm.left_elbow);
line(lm.left_elbow, lm.left_wrist);
line(lm.right_shoulder, lm.right_elbow);
line(lm.right_elbow, lm.right_wrist);
line(lm.left_shoulder, lm.left_hip);
line(lm.right_shoulder, lm.right_hip);
line(lm.left_hip, lm.right_hip);
line(lm.left_hip, lm.left_knee);
line(lm.left_knee, lm.left_ankle);
line(lm.right_hip, lm.right_knee);
line(lm.right_knee, lm.right_ankle);
Object.values(lm).forEach((p) => circ(p));
}
// ========== UI UPDATE FUNCTIONS ==========
function updateFallUI(res) {
let status = "SAFE";
if (res.help_active) status = "HELP";
else if (!res.safe && !res.sleeping) status = "EMERGENCY";
else if (res.sleeping) status = "SAFE (Sleeping)";
UI.fallStatus.textContent = status;
UI.fallStatus.classList.remove("safe", "alert", "help", "sleeping");
if (status === "SAFE") UI.fallStatus.classList.add("safe");
else if (status === "HELP") UI.fallStatus.classList.add("help");
else if (status === "SAFE (Sleeping)")
UI.fallStatus.classList.add("sleeping");
else UI.fallStatus.classList.add("alert");
UI.fallConfidence.textContent = `${Math.round(res.fall_confidence * 100)}%`;
UI.helpGesture.textContent = res.waving ? "WAVING" : "OFF";
if (UI.sleepingStatus)
UI.sleepingStatus.textContent = res.sleeping ? "YES" : "OFF";
UI.fallTimer.textContent = `${Math.round(res.timer)}s`;
// Update header status badge
if (UI.statusBadge) {
UI.statusBadge.textContent = status;
UI.statusBadge.classList.remove("safe", "alert");
if (status.startsWith("SAFE")) {
UI.statusBadge.classList.add("safe");
} else {
UI.statusBadge.classList.add("alert");
}
}
// Toast and Telegram
if (status !== STATE.fall.lastStatus) {
if (status === "HELP") {
showToast("HELP: Waving detected!");
maybeSendTelegramHelp();
} else if (status === "EMERGENCY") {
showToast("EMERGENCY: FALL!");
maybeSendTelegramFall(res.fall_confidence);
}
STATE.fall.lastStatus = status;
}
}
function updateRehabUI(res) {
// Update reps - for non-trackBothSides exercises, show combined reps in both cards
if (res.trackBothSides === false) {
UI.repsLeft.textContent = res.repsCombined || 0;
UI.repsRight.textContent = res.repsCombined || 0;
UI.stageLeft.textContent = res.stageCombined || "-";
UI.stageRight.textContent = res.stageCombined || "-";
} else {
UI.repsLeft.textContent = res.repsLeft;
UI.repsRight.textContent = res.repsRight;
UI.stageLeft.textContent = res.stageLeft || "-";
UI.stageRight.textContent = res.stageRight || "-";
}
UI.angleLeft.textContent =
res.angleLeft != null ? Math.round(res.angleLeft) : "-";
UI.angleRight.textContent =
res.angleRight != null ? Math.round(res.angleRight) : "-";
// Update form accuracy display
if (UI.accuracyValue) {
if (res.formAccuracy != null) {
const accuracy = Math.round(res.formAccuracy);
UI.accuracyValue.textContent = `${accuracy}%`;
// Color coding based on accuracy
UI.accuracyValue.classList.remove("warning", "poor");
if (accuracy < CONFIG.formAccuracy.warningThreshold) {
UI.accuracyValue.classList.add("poor");
} else if (accuracy < CONFIG.formAccuracy.goodThreshold) {
UI.accuracyValue.classList.add("warning");
}
} else {
UI.accuracyValue.textContent = "-";
UI.accuracyValue.classList.remove("warning", "poor");
}
}
// Update mean error display
if (UI.meanErrorValue) {
if (res.meanError != null) {
UI.meanErrorValue.textContent = `${res.meanError.toFixed(1)}°`;
} else {
UI.meanErrorValue.textContent = "-";
}
}
// Update accuracy bar
if (UI.accuracyBar) {
if (res.formAccuracy != null) {
const accuracy = Math.round(res.formAccuracy);
UI.accuracyBar.style.width = `${accuracy}%`;
// Color coding for bar
UI.accuracyBar.classList.remove("warning", "poor");
if (accuracy < CONFIG.formAccuracy.warningThreshold) {
UI.accuracyBar.classList.add("poor");
} else if (accuracy < CONFIG.formAccuracy.goodThreshold) {
UI.accuracyBar.classList.add("warning");
}
} else {
UI.accuracyBar.style.width = "0%";
UI.accuracyBar.classList.remove("warning", "poor");
}
}
}
function updateAnglesUI(angles) {
UI.angLeftElbow.textContent = `${Math.round(angles.left_elbow || 0)}°`;
UI.angRightElbow.textContent = `${Math.round(angles.right_elbow || 0)}°`;
UI.angLeftShoulder.textContent = `${Math.round(angles.left_shoulder || 0)}°`;
UI.angRightShoulder.textContent = `${Math.round(
angles.right_shoulder || 0
)}°`;
UI.angLeftHip.textContent = `${Math.round(angles.left_hip || 0)}°`;
UI.angRightHip.textContent = `${Math.round(angles.right_hip || 0)}°`;
UI.angLeftKnee.textContent = `${Math.round(angles.left_knee || 0)}°`;
UI.angRightKnee.textContent = `${Math.round(angles.right_knee || 0)}°`;
}
// ========== CAMERA FUNCTIONS ==========
async function startCamera() {
await enumerateCameras();
await startCameraWithDevice(currentCameraId);
}
function stopCamera() {
if (STATE.stream) {
STATE.stream.getTracks().forEach((track) => track.stop());
STATE.stream = null;
}
UI.video.srcObject = null;
UI.video.style.display = "none";
UI.overlay.style.display = "none";
if (UI.roiCanvas) UI.roiCanvas.style.display = "none";
UI.cameraPlaceholder.style.display = "flex";
STATE.cameraActive = false;
STATE.running = false;
setStatusText("Kamera tidak aktif");
// Clear canvas
const ctx = UI.overlay.getContext("2d");
ctx.clearRect(0, 0, UI.overlay.width, UI.overlay.height);
// Clear ROI canvas
if (UI.roiCanvas) {
const roiCtx = UI.roiCanvas.getContext("2d");
roiCtx.clearRect(0, 0, UI.roiCanvas.width, UI.roiCanvas.height);
}
}
function syncCanvasSize() {
const rect = UI.video.getBoundingClientRect();
UI.overlay.width = rect.width;
UI.overlay.height = rect.height;
// Sync ROI canvas size
if (UI.roiCanvas) {
UI.roiCanvas.width = rect.width;
UI.roiCanvas.height = rect.height;
drawROIOverlay();
}
}
// ========== MODEL LOADING ==========
async function loadModel() {
setStatusText("Memuat model MediaPipe...");
showLoading(true);
try {
const fileset = await FilesetResolver.forVisionTasks(
"https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm"
);
const modelURL =
"https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task";
STATE.landmarker = await PoseLandmarker.createFromOptions(fileset, {
baseOptions: { modelAssetPath: modelURL },
runningMode: "VIDEO",
numPoses: 1,
minPoseDetectionConfidence: 0.5,
minPoseTrackingConfidence: 0.5,
minPosePresenceConfidence: 0.5,
});
setStatusText("Model siap. Pilih fitur deteksi.");
showLoading(false);
} catch (err) {
console.error("Model load error:", err);
setStatusText("Gagal memuat model. Periksa koneksi.");
showLoading(false);
}
}
// ========== MAIN LOOP ==========
function loop() {
if (!STATE.running) return;
if (!STATE.landmarker || UI.video.readyState < 2) {
requestAnimationFrame(loop);
return;
}
const tMs = performance.now();
const results = STATE.landmarker.detectForVideo(UI.video, tMs);
const ctx = UI.overlay.getContext("2d");
ctx.clearRect(0, 0, UI.overlay.width, UI.overlay.height);
updateFPS();
const havePose = results.landmarks && results.landmarks.length > 0;
UI.poseStatus.textContent = havePose ? "Detected" : "No Pose";
if (havePose) {
const lmRaw = results.landmarks[0];
const dispW = UI.overlay.width,
dispH = UI.overlay.height;
const lmDisp = getPts(lmRaw, dispW, dispH);
const lmStream = getPts(lmRaw, CONFIG.streamW, CONFIG.streamH);
// Check pose calibration if modal is open
checkPoseCalibration(lmRaw);
// Draw skeleton if any detection is active
if (STATE.fallDetectionActive || STATE.rehabActive) {
drawSkeleton(ctx, lmDisp, dispW, dispH);
}
const t = tMs / 1000.0;
// Fall Detection
if (STATE.fallDetectionActive) {
const fallRes = updateFallDetection(t, lmStream);
updateFallUI(fallRes);
updateAnglesUI(fallRes.angles);
}
// Rehab Medic
if (STATE.rehabActive) {
const rehabRes = updateRehabMedic(lmRaw);
updateRehabUI(rehabRes);
// Update rehab mode tracking if active
if (STATE.rehab.rehabModeActive) {
updateRehabModeTracking(rehabRes);
}
// Update angles if fall detection is not active
if (!STATE.fallDetectionActive) {
const angles = computeAngles(lmStream);
updateAnglesUI(angles);
}
}
} else {
// Reset displays when no pose
if (STATE.fallDetectionActive) {
UI.fallConfidence.textContent = "0%";
UI.helpGesture.textContent = "OFF";
}
// Update calibration status if modal is open
checkPoseCalibration(null);
}
requestAnimationFrame(loop);
}
// ========== REHAB MODE FUNCTIONS ==========
function showRehabModeUI(mode) {
// mode: "standard", "setup", "active", "complete"
if (UI.standardExercisePanel) {
UI.standardExercisePanel.classList.toggle("hidden", mode !== "standard");
}
if (UI.rehabModeSetup) {
UI.rehabModeSetup.classList.toggle("hidden", mode !== "setup");
}
if (UI.rehabModeActive) {
UI.rehabModeActive.classList.toggle("hidden", mode !== "active");
}
if (UI.rehabModeComplete) {
UI.rehabModeComplete.classList.toggle("hidden", mode !== "complete");
}
STATE.rehab.rehabModePhase = mode;
}
function getExerciseDisplayName(type) {
const exercise = EXERCISES[type];
return exercise ? exercise.name : type;
}
function renderExerciseQueue() {
if (!UI.exerciseQueue) return;
UI.exerciseQueue.innerHTML = "";
STATE.rehab.exerciseQueue.forEach((item, index) => {
const div = document.createElement("div");
div.className = "exercise-queue-item";
div.innerHTML = `
<span class="order-num">${index + 1}</span>
<span class="exercise-name">${getExerciseDisplayName(item.type)}</span>
<span class="exercise-reps">${item.reps} reps</span>
<div class="move-btns">
<button class="move-up" data-index="${index}" ${
index === 0 ? "disabled" : ""
}>↑</button>
<button class="move-down" data-index="${index}" ${
index === STATE.rehab.exerciseQueue.length - 1 ? "disabled" : ""
}>↓</button>
</div>
<button class="remove-btn" data-index="${index}">×</button>
`;
UI.exerciseQueue.appendChild(div);
});
// Enable/disable start button based on queue length
if (UI.startRehabBtn) {
UI.startRehabBtn.disabled = STATE.rehab.exerciseQueue.length === 0;
}
}
function addExerciseToQueue() {
if (!UI.addExerciseType || !UI.addExerciseReps) return;
const type = UI.addExerciseType.value;
const repsValue = UI.addExerciseReps.value;
const reps = parseInt(repsValue, 10);
if (isNaN(reps) || reps < 1 || reps > 100) {
showToast("Jumlah repetisi harus 1-100");
return;
}
STATE.rehab.exerciseQueue.push({
type,
reps,
completedLeft: 0,
completedRight: 0,
});
renderExerciseQueue();
}
function removeExerciseFromQueue(index) {
if (index >= 0 && index < STATE.rehab.exerciseQueue.length) {
STATE.rehab.exerciseQueue.splice(index, 1);
renderExerciseQueue();
}
}
function moveExerciseInQueue(index, direction) {
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= STATE.rehab.exerciseQueue.length) return;
const temp = STATE.rehab.exerciseQueue[index];
STATE.rehab.exerciseQueue[index] = STATE.rehab.exerciseQueue[newIndex];
STATE.rehab.exerciseQueue[newIndex] = temp;
renderExerciseQueue();
}
function clearExerciseQueue() {
STATE.rehab.exerciseQueue = [];
renderExerciseQueue();
}
function startRehabWorkout() {
if (STATE.rehab.exerciseQueue.length === 0) {
showToast("Tambahkan latihan terlebih dahulu");
return;
}
// Reset all exercise progress
STATE.rehab.exerciseQueue.forEach((item) => {
item.completedLeft = 0;
item.completedRight = 0;
});
STATE.rehab.currentExerciseIndex = 0;
STATE.rehab.rehabModeActive = true;
STATE.rehab.startTime = Date.now();
STATE.rehab.endTime = null;
// Reset the internal rep counters
STATE.rehab.repsLeft = 0;
STATE.rehab.repsRight = 0;
STATE.rehab.repsCombined = 0;
STATE.rehab.stageLeft = null;
STATE.rehab.stageRight = null;
STATE.rehab.stageCombined = null;
// Set current exercise
const currentExercise = STATE.rehab.exerciseQueue[0];
STATE.rehab.currentExercise = currentExercise.type;
showRehabModeUI("active");
updateRehabModeActiveUI();
renderProgressQueue();
setStatusText(
`Rehab Mode aktif - ${getExerciseDisplayName(currentExercise.type)}`
);
}
function resetRehabWorkout() {
STATE.rehab.rehabModeActive = false;
STATE.rehab.currentExerciseIndex = 0;
STATE.rehab.startTime = null;
STATE.rehab.endTime = null;
// Reset internal counters
STATE.rehab.repsLeft = 0;
STATE.rehab.repsRight = 0;
STATE.rehab.repsCombined = 0;
STATE.rehab.stageLeft = null;
STATE.rehab.stageRight = null;
// Reset exercise queue progress
STATE.rehab.exerciseQueue.forEach((item) => {
item.completedLeft = 0;
item.completedRight = 0;
});
showRehabModeUI("setup");
renderExerciseQueue();
setStatusText("Rehab Mode - Setup");
}
function updateRehabModeActiveUI() {
if (!STATE.rehab.rehabModeActive) return;
const currentIndex = STATE.rehab.currentExerciseIndex;
if (currentIndex >= STATE.rehab.exerciseQueue.length) return;
const currentExercise = STATE.rehab.exerciseQueue[currentIndex];
const exerciseConfig = EXERCISES[currentExercise.type];
// Update current exercise display
if (UI.currentExerciseName) {
UI.currentExerciseName.textContent = getExerciseDisplayName(
currentExercise.type
);
}
// Update progress - for exercises that track both sides separately
const trackBothSides = exerciseConfig ? exerciseConfig.trackBothSides : true;
if (UI.rehabProgressLeft) {
UI.rehabProgressLeft.textContent = currentExercise.completedLeft;
}
if (UI.rehabProgressRight) {
UI.rehabProgressRight.textContent = currentExercise.completedRight;
}
if (UI.rehabTargetLeft) {
UI.rehabTargetLeft.textContent = currentExercise.reps;
}
if (UI.rehabTargetRight) {
UI.rehabTargetRight.textContent = currentExercise.reps;
}
// Update ENLARGED stats
updateEnlargedRehabStats(
currentExercise.completedLeft,
currentExercise.completedRight,
currentExercise.reps,
currentExercise.reps
);
// Update total/completed stats
if (UI.rehabTotalExercises) {
UI.rehabTotalExercises.textContent = STATE.rehab.exerciseQueue.length;
}
if (UI.rehabCompletedExercises) {
UI.rehabCompletedExercises.textContent = currentIndex;
}
}
function renderProgressQueue() {
if (!UI.progressQueue) return;
UI.progressQueue.innerHTML = "";
STATE.rehab.exerciseQueue.forEach((item, index) => {
const currentIndex = STATE.rehab.currentExerciseIndex;
const isCompleted = index < currentIndex;
const isCurrent = index === currentIndex;
const div = document.createElement("div");
div.className = `progress-queue-item ${isCurrent ? "current" : ""} ${
isCompleted ? "completed" : ""
}`;
let statusIcon = "○";
let statusClass = "pending";
if (isCompleted) {
statusIcon = "✓";
statusClass = "done";
} else if (isCurrent) {
statusIcon = "▶";
statusClass = "active";
}
const exerciseConfig = EXERCISES[item.type];
const trackBothSides = exerciseConfig
? exerciseConfig.trackBothSides
: true;
let progressText = "";
if (trackBothSides) {
progressText = `L: ${item.completedLeft}/${item.reps} | R: ${item.completedRight}/${item.reps}`;
} else {
const completed = Math.max(item.completedLeft, item.completedRight);
progressText = `${completed}/${item.reps}`;
}
div.innerHTML = `
<span class="status-icon ${statusClass}">${statusIcon}</span>
<div class="exercise-info">
<span class="name">${getExerciseDisplayName(item.type)}</span>
<span class="reps">${progressText}</span>
</div>
`;
UI.progressQueue.appendChild(div);
});
}
function checkExerciseCompletion() {
if (!STATE.rehab.rehabModeActive) return;
const currentIndex = STATE.rehab.currentExerciseIndex;
if (currentIndex >= STATE.rehab.exerciseQueue.length) return;
const currentExercise = STATE.rehab.exerciseQueue[currentIndex];
const exerciseConfig = EXERCISES[currentExercise.type];
const trackBothSides = exerciseConfig ? exerciseConfig.trackBothSides : true;
let isComplete = false;
if (trackBothSides) {
// Both sides need to reach target
isComplete =
currentExercise.completedLeft >= currentExercise.reps &&
currentExercise.completedRight >= currentExercise.reps;
} else {
// Either side reaching target counts (for squats, etc.)
const completed = Math.max(
currentExercise.completedLeft,
currentExercise.completedRight
);
isComplete = completed >= currentExercise.reps;
}
if (isComplete) {
// Move to next exercise
STATE.rehab.currentExerciseIndex++;
// Reset internal counters for next exercise
STATE.rehab.repsLeft = 0;
STATE.rehab.repsRight = 0;
STATE.rehab.repsCombined = 0;
STATE.rehab.stageLeft = null;
STATE.rehab.stageRight = null;
if (STATE.rehab.currentExerciseIndex >= STATE.rehab.exerciseQueue.length) {
// All exercises completed!
completeRehabWorkout();
} else {
// Set up next exercise
const nextExercise =
STATE.rehab.exerciseQueue[STATE.rehab.currentExerciseIndex];
STATE.rehab.currentExercise = nextExercise.type;
updateRehabModeActiveUI();
renderProgressQueue();
setStatusText(
`Rehab Mode - ${getExerciseDisplayName(nextExercise.type)}`
);
showToast(
`Latihan berikutnya: ${getExerciseDisplayName(nextExercise.type)}`
);
}
}
}
function completeRehabWorkout() {
STATE.rehab.rehabModeActive = false;
STATE.rehab.endTime = Date.now();
// Show completion screen
showRehabModeUI("complete");
// Calculate stats
const durationStr = formatDuration(
STATE.rehab.startTime,
STATE.rehab.endTime
);
const durationS = Math.round(
(STATE.rehab.endTime - STATE.rehab.startTime) / 1000
);
let totalReps = 0;
const exerciseNames = [];
STATE.rehab.exerciseQueue.forEach((item) => {
const exerciseConfig = EXERCISES[item.type];
exerciseNames.push(getExerciseDisplayName(item.type));
if (exerciseConfig && exerciseConfig.trackBothSides) {
totalReps += item.completedLeft + item.completedRight;
} else {
totalReps += Math.max(item.completedLeft, item.completedRight);
}
});
// Populate stats UI
if (UI.rehabCompleteStats) {
UI.rehabCompleteStats.innerHTML = `
<div class="stat-item">
<span>Total Latihan</span>
<span>${STATE.rehab.exerciseQueue.length}</span>
</div>
<div class="stat-item">
<span>Total Repetisi</span>
<span>${totalReps}</span>
</div>
<div class="stat-item">
<span>Durasi</span>
<span>${durationStr}</span>
</div>
`;
}
// Save rehab history
const rehabHistoryData = {
namaLatihan: exerciseNames.join(", "),
repetisi: STATE.rehab.exerciseQueue.reduce(
(sum, item) => sum + item.reps,
0
),
totalLatihan: STATE.rehab.exerciseQueue.length,
totalRepetisi: totalReps,
durasi: durationS,
};
saveRehabHistory(rehabHistoryData).then(() => {
// Reload history after saving
loadRehabHistory(false);
});
setStatusText("Rehab selesai! 🎉");
showToast("🎉 Rehab selesai!");
}
function updateRehabModeTracking(rehabRes) {
if (!STATE.rehab.rehabModeActive) return;
const currentIndex = STATE.rehab.currentExerciseIndex;
if (currentIndex >= STATE.rehab.exerciseQueue.length) return;
const currentExercise = STATE.rehab.exerciseQueue[currentIndex];
// Update completed reps from the tracking result
currentExercise.completedLeft = rehabRes.repsLeft;
currentExercise.completedRight = rehabRes.repsRight;
// Update UI
updateRehabModeActiveUI();
renderProgressQueue();
// Update FPS/Pose status in active panel
if (UI.fpsDisplayRehab) {
UI.fpsDisplayRehab.textContent = UI.fpsDisplay.textContent;
}
if (UI.poseStatusRehab) {
UI.poseStatusRehab.textContent = UI.poseStatus.textContent;
}
// Check if current exercise is complete
checkExerciseCompletion();
}
// ========== HISTORY PANEL FUNCTIONS ==========
// Format date to Indonesian format
function formatIndonesianDate(date) {
const months = [
"Januari",
"Februari",
"Maret",
"April",
"Mei",
"Juni",
"Juli",
"Agustus",
"September",
"Oktober",
"November",
"Desember",
];
let d;
if (date instanceof Date) {
d = date;
} else if (date && date.toDate) {
// Firestore Timestamp
d = date.toDate();
} else if (typeof date === "string") {
d = new Date(date);
} else {
d = new Date();
}
const day = d.getDate();
const month = months[d.getMonth()];
const year = d.getFullYear();
const hours = d.getHours().toString().padStart(2, "0");
const minutes = d.getMinutes().toString().padStart(2, "0");
return `${day} ${month} ${year}, ${hours}:${minutes}`;
}
// Format duration in seconds to readable format
function formatDurationSeconds(seconds) {
if (typeof seconds !== "number" || isNaN(seconds)) return "-";
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
// Show/hide history panel UI elements
function updateHistoryUI() {
if (!UI.historyPanel) return;
// Show/hide loading
if (UI.historyLoading) {
UI.historyLoading.classList.toggle("hidden", !STATE.history.isLoading);
}
// Show/hide empty state
const isEmpty = !STATE.history.isLoading && STATE.history.items.length === 0;
if (UI.historyEmpty) {
UI.historyEmpty.classList.toggle("hidden", !isEmpty);
}
// Show/hide load more button (Firestore only)
if (UI.historyLoadMore) {
const showLoadMore =
STATE.auth.user && STATE.history.hasMore && !STATE.history.isLoading;
UI.historyLoadMore.classList.toggle("hidden", !showLoadMore);
}
// Show/hide guest warning
if (UI.historyGuestWarning) {
UI.historyGuestWarning.classList.toggle("hidden", !STATE.auth.isGuest);
}
}
// Render a single history item
function renderHistoryItem(item, container) {
const div = document.createElement("div");
div.className = "history-item";
div.dataset.itemId = item.id || "";
const dateStr = formatIndonesianDate(item.timestamp);
const durationStr = formatDurationSeconds(item.durasi);
div.innerHTML = `
<div class="history-item-header">
<div class="history-item-date">
<span>📅</span>
<span>${dateStr}</span>
</div>
<button class="history-item-delete" data-item-id="${
item.id || ""
}" title="Hapus">🗑️</button>
</div>
<div class="history-item-exercises">
<span>📋</span>
<span>${item.namaLatihan || "-"}</span>
</div>
<div class="history-item-stats">
<div class="history-item-stat">
<span>🔁</span>
<span>Latihan: <strong>${item.totalLatihan || 0}</strong></span>
</div>
<div class="history-item-stat">
<span>💪</span>
<span>Repetisi: <strong>${item.totalRepetisi || 0}</strong></span>
</div>
<div class="history-item-stat">
<span>⏱️</span>
<span>Durasi: <strong>${durationStr}</strong></span>
</div>
</div>
`;
container.appendChild(div);
}
// Load rehab history
async function loadRehabHistory(loadMore = false) {
if (STATE.history.isLoading) return;
STATE.history.isLoading = true;
updateHistoryUI();
const firebaseService = await loadFirebase();
try {
if (STATE.auth.user && firebaseService) {
// Load from Firestore with pagination
const lastDoc = loadMore ? STATE.history.lastDoc : null;
const result = await firebaseService.getRehabHistoryPaginated(
10,
lastDoc
);
if (result.success) {
if (loadMore) {
STATE.history.items = [...STATE.history.items, ...result.data];
} else {
STATE.history.items = result.data;
}
STATE.history.lastDoc = result.lastDoc;
STATE.history.hasMore = result.hasMore;
} else {
console.error("Failed to load history from Firestore:", result.error);
}
} else if (STATE.auth.isGuest) {
// Load from localStorage
const history = JSON.parse(
localStorage.getItem("guestRehabHistory") || "[]"
);
// Sort by timestamp descending and add temporary IDs
STATE.history.items = history
.map((item, index) => ({ ...item, id: `local_${index}` }))
.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
STATE.history.hasMore = false;
STATE.history.lastDoc = null;
}
} catch (error) {
console.error("Error loading rehab history:", error);
}
STATE.history.isLoading = false;
renderHistoryList();
updateHistoryUI();
}
// Render the history list
function renderHistoryList() {
if (!UI.historyList) return;
UI.historyList.innerHTML = "";
STATE.history.items.forEach((item) => {
renderHistoryItem(item, UI.historyList);
});
}
// Delete history item
async function deleteHistoryItem(itemId) {
if (!itemId) return;
const confirmed = confirm("Apakah Anda yakin ingin menghapus riwayat ini?");
if (!confirmed) return;
const firebaseService = await loadFirebase();
try {
if (STATE.auth.user && firebaseService && !itemId.startsWith("local_")) {
// Delete from Firestore
const result = await firebaseService.deleteRehabHistoryFromFirestore(
itemId
);
if (result.success) {
showToast("✅ Riwayat dihapus");
} else {
showToast("❌ Gagal menghapus riwayat");
return;
}
} else if (STATE.auth.isGuest) {
// Delete from localStorage - find by timestamp match
const sortedHistory = JSON.parse(
localStorage.getItem("guestRehabHistory") || "[]"
);
const itemIndex = STATE.history.items.findIndex(
(item) => item.id === itemId
);
if (itemIndex !== -1) {
const targetTimestamp = STATE.history.items[itemIndex].timestamp;
const originalIndex = sortedHistory.findIndex(
(item) => item.timestamp === targetTimestamp
);
if (originalIndex !== -1) {
sortedHistory.splice(originalIndex, 1);
localStorage.setItem(
"guestRehabHistory",
JSON.stringify(sortedHistory)
);
}
}
showToast("✅ Riwayat dihapus");
}
// Remove from state and re-render
STATE.history.items = STATE.history.items.filter(
(item) => item.id !== itemId
);
renderHistoryList();
updateHistoryUI();
} catch (error) {
console.error("Error deleting history item:", error);
showToast("❌ Gagal menghapus riwayat");
}
}
// Show history panel when rehab is active
function updateHistoryPanelVisibility() {
if (!UI.historyPanel) return;
// Show history panel when rehab medic is active
UI.historyPanel.classList.toggle("hidden", !STATE.rehabActive);
}
// ========== CAMERA ENUMERATION AND SWITCHING ==========
let availableCameras = [];
let currentCameraId = null;
async function enumerateCameras() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
availableCameras = devices.filter((d) => d.kind === "videoinput");
renderCameraList();
} catch (error) {
console.error("Error enumerating cameras:", error);
}
}
function renderCameraList() {
const list = document.getElementById("camera-list");
if (!list) return;
list.innerHTML = "";
if (availableCameras.length === 0) {
list.innerHTML =
'<div style="padding: 8px; color: var(--muted); font-size: 12px;">Tidak ada kamera</div>';
return;
}
availableCameras.forEach((cam, i) => {
const div = document.createElement("div");
div.className = "camera-item";
if (cam.deviceId === currentCameraId) div.classList.add("active");
const label = cam.label || `Camera ${i + 1}`;
div.innerHTML = `
<span>📹</span>
<span style="flex: 1">${label}</span>
${cam.deviceId === currentCameraId ? "<span>✓</span>" : ""}
`;
div.onclick = () => switchCamera(cam.deviceId);
list.appendChild(div);
});
}
async function switchCamera(deviceId) {
if (deviceId === currentCameraId) return;
currentCameraId = deviceId;
if (STATE.cameraActive) {
stopCamera();
await startCameraWithDevice(deviceId);
}
renderCameraList();
document.getElementById("hamburger-dropdown")?.classList.add("hidden");
showToast("✅ Kamera berhasil diubah");
}
async function startCameraWithDevice(deviceId) {
try {
showLoading(true);
setStatusText("Memulai kamera...");
const constraints = {
video: deviceId
? {
deviceId: { ideal: deviceId },
width: { ideal: CONFIG.streamW },
height: { ideal: CONFIG.streamH },
}
: {
facingMode: "user",
width: { ideal: CONFIG.streamW },
height: { ideal: CONFIG.streamH },
},
audio: false,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
STATE.stream = stream;
UI.video.srcObject = stream;
await UI.video.play();
UI.cameraPlaceholder.style.display = "none";
UI.video.style.display = "block";
UI.overlay.style.display = "block";
if (UI.roiCanvas) UI.roiCanvas.style.display = "block";
syncCanvasSize();
STATE.cameraActive = true;
setStatusText("Kamera aktif. Pilih fitur deteksi.");
showLoading(false);
// Load model if not already loaded
if (!STATE.landmarker) {
await loadModel();
}
STATE.running = true;
requestAnimationFrame(loop);
} catch (err) {
console.error("Camera error:", err);
setStatusText("Gagal mengakses kamera. Periksa izin.");
showLoading(false);
UI.toggleCamera.checked = false;
STATE.cameraActive = false;
}
}
// ========== EVENT HANDLERS ==========
UI.toggleCamera.addEventListener("change", async (e) => {
if (e.target.checked) {
await startCamera();
} else {
stopCamera();
}
updateFeatureToggles();
});
UI.toggleFall.addEventListener("change", (e) => {
if (!STATE.cameraActive) {
e.target.checked = false;
return;
}
if (e.target.checked) {
// Show calibration modal before enabling
showCalibrationModal("fall");
e.target.checked = false; // Reset until calibration is confirmed
} else {
STATE.fallDetectionActive = false;
updateFeatureToggles();
if (STATE.rehabActive) {
setStatusText("Rehab Medic aktif");
} else {
setStatusText("Kamera aktif. Pilih fitur deteksi.");
}
}
});
UI.toggleRehab.addEventListener("change", (e) => {
if (!STATE.cameraActive) {
e.target.checked = false;
return;
}
if (e.target.checked) {
// Show calibration modal before enabling
showCalibrationModal("rehab");
e.target.checked = false; // Reset until calibration is confirmed
} else {
STATE.rehabActive = false;
updateFeatureToggles();
if (STATE.fallDetectionActive) {
setStatusText("Fall Detection aktif");
} else {
setStatusText("Kamera aktif. Pilih fitur deteksi.");
}
}
});
UI.resetCounter.addEventListener("click", resetRehabCounter);
// Exercise selector event handler
if (UI.exerciseSelect) {
UI.exerciseSelect.addEventListener("change", (e) => {
const value = e.target.value;
switchExercise(value);
if (STATE.rehabActive) {
if (value === "rehab_mode") {
setStatusText("Rehab Mode - Setup");
} else {
const exercise = EXERCISES[value];
setStatusText(
`Rehab Medic aktif - ${exercise ? exercise.name : "Unknown"}`
);
}
}
});
}
// Rehab Mode event handlers
if (UI.addExerciseBtn) {
UI.addExerciseBtn.addEventListener("click", addExerciseToQueue);
}
if (UI.exerciseQueue) {
UI.exerciseQueue.addEventListener("click", (e) => {
const target = e.target;
if (target.classList.contains("remove-btn")) {
const index = parseInt(target.dataset.index, 10);
removeExerciseFromQueue(index);
} else if (target.classList.contains("move-up")) {
const index = parseInt(target.dataset.index, 10);
moveExerciseInQueue(index, -1);
} else if (target.classList.contains("move-down")) {
const index = parseInt(target.dataset.index, 10);
moveExerciseInQueue(index, 1);
}
});
}
if (UI.startRehabBtn) {
UI.startRehabBtn.addEventListener("click", startRehabWorkout);
}
if (UI.clearQueueBtn) {
UI.clearQueueBtn.addEventListener("click", clearExerciseQueue);
}
if (UI.resetRehabBtn) {
UI.resetRehabBtn.addEventListener("click", resetRehabWorkout);
}
if (UI.restartRehabBtn) {
UI.restartRehabBtn.addEventListener("click", resetRehabWorkout);
}
// Handle window resize
window.addEventListener("resize", () => {
if (STATE.cameraActive) {
syncCanvasSize();
}
});
// Audio initialization on first interaction
document.body.addEventListener(
"click",
() => {
try {
UI.audio
.play()
.then(() => UI.audio.pause())
.catch(() => {});
} catch {}
},
{ once: true }
);
// Calibration modal event handlers
if (UI.calibrationConfirmBtn) {
UI.calibrationConfirmBtn.addEventListener("click", confirmCalibration);
}
if (UI.calibrationSkipBtn) {
UI.calibrationSkipBtn.addEventListener("click", skipCalibration);
}
// Logout button event handler
if (UI.logoutBtn) {
UI.logoutBtn.addEventListener("click", handleLogout);
}
// History panel event handlers
if (UI.historyRefreshBtn) {
UI.historyRefreshBtn.addEventListener("click", () => {
loadRehabHistory(false);
});
}
if (UI.historyLoadMore) {
UI.historyLoadMore.addEventListener("click", () => {
loadRehabHistory(true);
});
}
if (UI.historyLoginBtn) {
UI.historyLoginBtn.addEventListener("click", () => {
window.location.href = "login.html";
});
}
// Event delegation for history item delete buttons
if (UI.historyList) {
UI.historyList.addEventListener("click", (e) => {
const deleteBtn = e.target.closest(".history-item-delete");
if (deleteBtn) {
const itemId = deleteBtn.dataset.itemId;
deleteHistoryItem(itemId);
}
});
}
// Hamburger menu toggle
document.getElementById("hamburger-btn")?.addEventListener("click", (e) => {
e.stopPropagation();
document.getElementById("hamburger-dropdown")?.classList.toggle("hidden");
});
// Close dropdown on outside click
document.addEventListener("click", (e) => {
const dropdown = document.getElementById("hamburger-dropdown");
if (dropdown && !e.target.closest(".hamburger-menu")) {
dropdown.classList.add("hidden");
}
});
// ========== INITIALIZATION ==========
async function init() {
// Load Telegram configuration from server
await initTelegramConfig();
// Load saved ROI from localStorage
STATE.bedROI = loadROI();
updateROIStatus();
// Attach ROI events
attachRoiEvents();
// Initialize exercise selector with default exercise
if (UI.exerciseSelect) {
UI.exerciseSelect.value = STATE.rehab.currentExercise;
const exercise = EXERCISES[STATE.rehab.currentExercise];
if (exercise) {
if (UI.exerciseTitle) UI.exerciseTitle.textContent = exercise.name;
if (UI.statLabelLeft) UI.statLabelLeft.textContent = exercise.labelLeft;
if (UI.statLabelRight)
UI.statLabelRight.textContent = exercise.labelRight;
}
}
setStatusText("Kamera belum aktif");
updateFeatureToggles();
// Update status indicators
updateStatusIndicators();
// Initialize authentication
await initializeAuth();
// Enumerate cameras for camera selector
await enumerateCameras();
// Expose updateStatusIndicators to window for external calls
window.updateStatusIndicators = updateStatusIndicators;
}
init();