3380 lines
98 KiB
JavaScript
3380 lines
98 KiB
JavaScript
// 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(); |