// 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 = '☁️Data disimpan ke Firebase'; } else if (STATE.auth.isGuest) { indicator.className = "storage-indicator guest"; indicator.innerHTML = '⚠️Data disimpan sementara (mode tamu)'; } } 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 = ` ${index + 1} ${getExerciseDisplayName(item.type)} ${item.reps} reps
`; 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 = ` ${statusIcon}
${getExerciseDisplayName(item.type)} ${progressText}
`; 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 = `
Total Latihan ${STATE.rehab.exerciseQueue.length}
Total Repetisi ${totalReps}
Durasi ${durationStr}
`; } // 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 = `
📅 ${dateStr}
📋 ${item.namaLatihan || "-"}
🔁 Latihan: ${item.totalLatihan || 0}
💪 Repetisi: ${item.totalRepetisi || 0}
⏱️ Durasi: ${durationStr}
`; 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 = '
Tidak ada kamera
'; 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 = ` 📹 ${label} ${cam.deviceId === currentCameraId ? "" : ""} `; 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();