Abdurrehman999's picture
Update index.html
2caaa52 verified
Raw
History Blame Contribute Delete
49.9 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PostureCoach — Real-Time Posture Analysis</title>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/pose/pose.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0d0f14;
--surface: #141720;
--surface2: #1c2030;
--border: #252a3a;
--good: #22c55e;
--good-dim: rgba(34,197,94,0.12);
--bad: #f97316;
--bad-dim: rgba(249,115,22,0.12);
--accent: #6366f1;
--accent-dim: rgba(99,102,241,0.15);
--text: #e8eaf0;
--text-muted: #6b7280;
--text-dim: #9ca3af;
--radius: 12px;
--radius-sm: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Inter', sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
overflow-x: hidden;
}
/* ── Header ── */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 28px;
border-bottom: 1px solid var(--border);
background: var(--surface);
position: sticky;
top: 0;
z-index: 100;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 1.15rem;
letter-spacing: -0.02em;
}
.logo-icon {
width: 32px; height: 32px;
background: linear-gradient(135deg, var(--accent), #a78bfa);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
font-size: 16px;
}
.header-right {
display: flex;
align-items: center;
gap: 14px;
}
.status-pill {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 14px;
border-radius: 20px;
font-size: 0.78rem;
font-weight: 600;
background: var(--surface2);
border: 1px solid var(--border);
transition: all 0.3s;
}
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--text-muted);
transition: all 0.3s;
}
.status-pill.active .status-dot { background: var(--good); box-shadow: 0 0 8px var(--good); animation: pulse 1.5s infinite; }
.status-pill.bad-pose { background: var(--bad-dim); border-color: var(--bad); }
.status-pill.bad-pose .status-dot { background: var(--bad); box-shadow: 0 0 8px var(--bad); }
.status-pill.good-pose { background: var(--good-dim); border-color: var(--good); }
.status-pill.good-pose .status-dot { background: var(--good); }
@keyframes pulse {
0%,100% { opacity: 1; } 50% { opacity: 0.4; }
}
/* ── Layout ── */
.app {
display: grid;
grid-template-columns: 1fr 360px;
gap: 0;
min-height: calc(100vh - 65px);
}
/* ── Main camera area ── */
.camera-panel {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.camera-container {
position: relative;
border-radius: var(--radius);
overflow: hidden;
background: #000;
border: 1px solid var(--border);
aspect-ratio: 16/9;
max-height: 420px;
}
#videoElement {
width: 100%; height: 100%;
object-fit: cover;
display: block;
transform: scaleX(-1);
}
#canvasElement {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
transform: scaleX(-1);
}
.camera-overlay {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
padding: 16px;
}
.pose-badge {
align-self: flex-end;
padding: 8px 16px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 700;
backdrop-filter: blur(12px);
border: 2px solid transparent;
transition: all 0.3s;
letter-spacing: 0.02em;
}
.pose-badge.good { background: rgba(34,197,94,0.85); color: #fff; border-color: var(--good); }
.pose-badge.bad { background: rgba(249,115,22,0.85); color: #fff; border-color: var(--bad); animation: shake 0.4s; }
.pose-badge.idle { background: rgba(0,0,0,0.6); color: var(--text-dim); border-color: var(--border); }
@keyframes shake {
0%,100%{transform:translateX(0)} 25%{transform:translateX(-4px)} 75%{transform:translateX(4px)}
}
.angle-display {
display: flex;
gap: 10px;
}
.angle-chip {
padding: 5px 10px;
border-radius: 8px;
background: rgba(0,0,0,0.7);
backdrop-filter: blur(8px);
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
color: var(--text-dim);
border: 1px solid rgba(255,255,255,0.1);
}
/* ── Camera controls ── */
.controls {
display: flex;
gap: 12px;
align-items: center;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover { background: #4f52e0; transform: translateY(-1px); }
.btn-secondary {
background: var(--surface2);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover { background: var(--border); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none !important; }
/* ── Stats row ── */
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.stat-label {
font-size: 0.72rem;
color: var(--text-muted);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.stat-value {
font-size: 1.6rem;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
line-height: 1;
}
.stat-value.good { color: var(--good); }
.stat-value.bad { color: var(--bad); }
.stat-value.accent { color: var(--accent); }
.stat-sub {
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 2px;
}
/* ── Session bar chart ── */
.session-chart {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
}
.chart-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-dim);
margin-bottom: 14px;
}
.progress-track {
height: 28px;
background: var(--surface2);
border-radius: 6px;
overflow: hidden;
position: relative;
display: flex;
}
.progress-good {
height: 100%;
background: linear-gradient(90deg, #22c55e, #4ade80);
transition: width 0.6s ease;
display: flex;
align-items: center;
padding-left: 10px;
font-size: 0.72rem;
font-weight: 700;
color: #052e16;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.progress-bad {
height: 100%;
background: linear-gradient(90deg, #f97316, #fb923c);
transition: width 0.6s ease;
display: flex;
align-items: center;
padding-left: 10px;
font-size: 0.72rem;
font-weight: 700;
color: #431407;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.mini-timeline {
display: flex;
gap: 3px;
margin-top: 12px;
height: 32px;
align-items: flex-end;
}
.timeline-bar {
flex: 1;
border-radius: 3px 3px 0 0;
min-height: 4px;
transition: height 0.3s;
}
.timeline-bar.good { background: var(--good); opacity: 0.7; }
.timeline-bar.bad { background: var(--bad); opacity: 0.7; }
.timeline-bar.idle { background: var(--border); }
/* ── Sidebar ── */
.sidebar {
border-left: 1px solid var(--border);
background: var(--surface);
display: flex;
flex-direction: column;
overflow: hidden;
}
.sidebar-tabs {
display: flex;
border-bottom: 1px solid var(--border);
}
.tab {
flex: 1;
padding: 14px 10px;
text-align: center;
font-size: 0.78rem;
font-weight: 600;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
background: none;
border-top: none;
border-left: none;
border-right: none;
font-family: inherit;
}
.tab.active {
color: var(--accent);
border-bottom-color: var(--accent);
background: var(--accent-dim);
}
.tab-panel {
display: none;
flex: 1;
overflow-y: auto;
padding: 16px;
flex-direction: column;
gap: 12px;
}
.tab-panel.active { display: flex; }
/* ── Notifications ── */
.alert-settings {
background: var(--surface2);
border-radius: var(--radius);
padding: 14px;
border: 1px solid var(--border);
}
.alert-title {
font-size: 0.8rem;
font-weight: 600;
margin-bottom: 10px;
color: var(--text-dim);
}
.toggle-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--border);
font-size: 0.82rem;
}
.toggle-row:last-child { border-bottom: none; }
.toggle {
position: relative;
width: 36px; height: 20px;
}
.toggle input { opacity: 0; width: 0; height: 0; }
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background: var(--border);
border-radius: 20px;
transition: 0.3s;
}
.slider:before {
content: '';
position: absolute;
height: 14px; width: 14px;
left: 3px; bottom: 3px;
background: white;
border-radius: 50%;
transition: 0.3s;
}
input:checked + .slider { background: var(--accent); }
input:checked + .slider:before { transform: translateX(16px); }
.notif-feed {
display: flex;
flex-direction: column;
gap: 8px;
}
.notif-item {
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 0.78rem;
display: flex;
flex-direction: column;
gap: 3px;
animation: slideIn 0.3s ease;
border-left: 3px solid transparent;
}
.notif-item.bad {
background: var(--bad-dim);
border-left-color: var(--bad);
}
.notif-item.good {
background: var(--good-dim);
border-left-color: var(--good);
}
.notif-time { font-size: 0.68rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
@keyframes slideIn {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
/* ── AI Panel ── */
.ai-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.ai-badge {
font-size: 0.65rem;
padding: 3px 8px;
background: var(--accent-dim);
color: var(--accent);
border-radius: 10px;
font-weight: 600;
border: 1px solid rgba(99,102,241,0.3);
}
.model-select {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
padding: 9px 12px;
border-radius: var(--radius-sm);
font-size: 0.8rem;
font-family: inherit;
cursor: pointer;
margin-bottom: 4px;
}
.hf-token-input {
width: 100%;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
padding: 9px 12px;
border-radius: var(--radius-sm);
font-size: 0.8rem;
font-family: 'JetBrains Mono', monospace;
margin-bottom: 4px;
outline: none;
}
.hf-token-input:focus { border-color: var(--accent); }
.hf-token-input::placeholder { color: var(--text-muted); font-family: inherit; }
.input-label {
font-size: 0.72rem;
color: var(--text-muted);
margin-bottom: 4px;
display: block;
}
.ai-response-box {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px;
min-height: 120px;
font-size: 0.8rem;
line-height: 1.6;
color: var(--text-dim);
flex: 1;
overflow-y: auto;
white-space: pre-wrap;
}
.ai-response-box.loading {
display: flex;
align-items: center;
gap: 10px;
color: var(--accent);
}
.ai-disclaimer {
font-size: 0.68rem;
color: var(--text-muted);
margin-top: 6px;
padding: 8px 10px;
background: var(--surface2);
border-radius: 6px;
border-left: 2px solid var(--accent);
}
.spinner {
width: 16px; height: 16px;
border: 2px solid var(--accent-dim);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Toast ── */
#toast-container {
position: fixed;
top: 80px;
right: 20px;
z-index: 9999;
display: flex;
flex-direction: column;
gap: 10px;
pointer-events: none;
}
.toast {
padding: 12px 18px;
border-radius: var(--radius-sm);
font-size: 0.82rem;
font-weight: 600;
max-width: 300px;
pointer-events: auto;
animation: toastIn 0.3s ease;
display: flex;
align-items: center;
gap: 10px;
box-shadow: 0 4px 20px rgba(0,0,0,0.4);
}
.toast.bad { background: #7c2d12; color: #fed7aa; border: 1px solid var(--bad); }
.toast.good { background: #052e16; color: #bbf7d0; border: 1px solid var(--good); }
.toast.info { background: #1e1b4b; color: #c7d2fe; border: 1px solid var(--accent); }
@keyframes toastIn {
from { opacity: 0; transform: translateX(40px); }
to { opacity: 1; transform: translateX(0); }
}
/* ── History chart sparkline ── */
.angle-chart {
background: var(--surface2);
border-radius: var(--radius-sm);
padding: 12px;
border: 1px solid var(--border);
margin-top: 4px;
}
.angle-chart canvas {
width: 100% !important;
height: 70px !important;
display: block;
}
.chart-labels {
display: flex;
justify-content: space-between;
font-size: 0.65rem;
color: var(--text-muted);
margin-top: 4px;
font-family: 'JetBrains Mono', monospace;
}
/* Start screen */
.start-overlay {
position: absolute;
inset: 0;
background: rgba(13,15,20,0.92);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
z-index: 10;
border-radius: var(--radius);
backdrop-filter: blur(4px);
}
.start-overlay h2 { font-size: 1.2rem; font-weight: 700; }
.start-overlay p { font-size: 0.82rem; color: var(--text-muted); text-align: center; max-width: 220px; line-height: 1.5; }
.camera-icon { font-size: 48px; opacity: 0.6; }
.section-title {
font-size: 0.72rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-top: 4px;
margin-bottom: 2px;
}
/* Skeleton */
.empty-state {
text-align: center;
padding: 30px 10px;
color: var(--text-muted);
font-size: 0.8rem;
}
/* scrollbar */
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; }
.sidebar { border-left: none; border-top: 1px solid var(--border); min-height: 500px; }
.stats-row { grid-template-columns: repeat(2,1fr); }
}
</style>
</head>
<body>
<!-- Header -->
<header>
<div class="logo">
<div class="logo-icon">🧘</div>
PostureCoach
</div>
<div class="header-right">
<div class="status-pill" id="statusPill">
<div class="status-dot"></div>
<span id="statusText">Camera off</span>
</div>
</div>
</header>
<h3>
ROLE: You are an expert AI and computer-vision web developer.
TASK: Build a complete browser-based mini project called PostureCoach.
DESCRIPTION: Create a real-time posture monitoring app using the webcam.
DETAIL: Use MediaPipe Pose to detect 33 body landmarks and calculate shoulder, neck, and torso angles.
CLASSIFICATION: Detect and display Good Posture and Bad Posture/Slouching with a visual skeleton overlay.
BAD POSE: Add Face Too Close detection using Face Detection when only the eyes, nose, mouth, or a large part of the face is visible.
DASHBOARD: Show posture status, session time, good/bad frames, angles, alerts, and posture percentage.
UI & DEPLOYMENT: Create a clean responsive HTML/CSS/JavaScript app that can run as a static Hugging Face Space.
OUTPUT: Give me complete runnable code, explain each major part simply, and include testing steps.
</h3>
<!-- Toast container -->
<div id="toast-container"></div>
<!-- Main app -->
<div class="app">
<!-- Left: camera + stats -->
<div class="camera-panel">
<!-- Camera -->
<div class="camera-container" id="cameraContainer">
<video id="videoElement" autoplay muted playsinline></video>
<canvas id="canvasElement"></canvas>
<div class="camera-overlay">
<div style="display:flex;gap:8px;align-self:flex-end;flex-direction:column;align-items:flex-end">
<div class="pose-badge idle" id="poseBadge">Waiting…</div>
<div class="pose-badge idle" id="proximityBadge" style="display:none;font-size:0.75rem">📏 Distance OK</div>
</div>
<div class="angle-display" id="angleDisplay" style="display:none">
<div class="angle-chip" id="chipShoulder">Shoulders —</div>
<div class="angle-chip" id="chipNeck">Neck —</div>
<div class="angle-chip" id="chipTilt">Torso —</div>
<div class="angle-chip" id="chipDist">Dist —</div>
</div>
</div>
<div class="start-overlay" id="startOverlay">
<div class="camera-icon">📷</div>
<h2>Start your session</h2>
<p>Enable your camera to begin real-time posture analysis</p>
</div>
</div>
<!-- Controls -->
<div class="controls">
<button class="btn btn-primary" id="startBtn" onclick="startCamera()">▶ Start Camera</button>
<button class="btn btn-secondary" id="stopBtn" onclick="stopCamera()" disabled>■ Stop</button>
<button class="btn btn-secondary" onclick="resetSession()">↺ Reset</button>
</div>
<!-- Stats row -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">Good Posture</div>
<div class="stat-value good" id="goodCount">0</div>
<div class="stat-sub" id="goodPct">0% of session</div>
</div>
<div class="stat-card">
<div class="stat-label">Bad Posture</div>
<div class="stat-value bad" id="badCount">0</div>
<div class="stat-sub" id="badPct">0% of session</div>
</div>
<div class="stat-card">
<div class="stat-label">Bad Streaks</div>
<div class="stat-value accent" id="streakCount">0</div>
<div class="stat-sub">Longest: <span id="longestStreak">0</span>s</div>
</div>
<div class="stat-card">
<div class="stat-label">Session Time</div>
<div class="stat-value accent" id="sessionTime">0:00</div>
<div class="stat-sub">Active monitoring</div>
</div>
</div>
<!-- Session bar -->
<div class="session-chart">
<div class="chart-title">Session breakdown</div>
<div class="progress-track">
<div class="progress-good" id="progressGood" style="width:0%">
<span id="progressGoodLabel"></span>
</div>
<div class="progress-bad" id="progressBad" style="width:0%">
<span id="progressBadLabel"></span>
</div>
</div>
<div class="mini-timeline" id="miniTimeline">
<!-- filled dynamically -->
</div>
<div class="chart-labels">
<span>Session start</span>
<span>Now</span>
</div>
</div>
<!-- Angle sparkline -->
<div class="angle-chart">
<div class="chart-title" style="margin-bottom:8px">Shoulder alignment angle over time</div>
<canvas id="sparkCanvas" height="70"></canvas>
<div class="chart-labels"><span>Earlier</span><span>Now</span></div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-tabs">
<button class="tab active" onclick="switchTab('alerts')">🔔 Alerts</button>
<button class="tab" onclick="switchTab('ai')">🤖 AI Coach</button>
<button class="tab" onclick="switchTab('log')">📋 Log</button>
</div>
<!-- Alerts panel -->
<div class="tab-panel active" id="tab-alerts">
<div class="alert-settings">
<div class="alert-title">Notification settings</div>
<div class="toggle-row">
<span>Sound alarm on bad pose</span>
<label class="toggle">
<input type="checkbox" id="toggleSound" checked>
<span class="slider"></span>
</label>
</div>
<div class="toggle-row">
<span>Browser notifications</span>
<label class="toggle">
<input type="checkbox" id="toggleNotif">
<span class="slider"></span>
</label>
</div>
<div class="toggle-row">
<span>Alert every (bad seconds)</span>
<select class="model-select" id="alertInterval" style="width:90px;margin:0;padding:5px 8px">
<option value="3">3s</option>
<option value="5" selected>5s</option>
<option value="10">10s</option>
<option value="15">15s</option>
</select>
</div>
</div>
<div class="section-title">Recent alerts</div>
<div class="notif-feed" id="notifFeed">
<div class="empty-state">No alerts yet. Start the camera.</div>
</div>
</div>
<!-- AI Coach panel -->
<div class="tab-panel" id="tab-ai">
<div class="ai-header">
<span class="section-title" style="margin:0">AI Recommendation</span>
<span class="ai-badge">AI ✦</span>
</div>
<label class="input-label">Hugging Face Token (required)</label>
<input class="hf-token-input" type="password" id="hfToken"
placeholder="hf_xxxxxxxxxxxxxxxxxxxx"
value="">
<label class="input-label">Select AI model</label>
<select class="model-select" id="modelSelect">
<optgroup label="Meta Llama">
<option value="meta-llama/Llama-3.2-3B-Instruct">Llama 3.2 3B Instruct</option>
<option value="meta-llama/Llama-3.1-8B-Instruct">Llama 3.1 8B Instruct</option>
</optgroup>
<optgroup label="Mistral">
<option value="mistralai/Mistral-7B-Instruct-v0.3">Mistral 7B Instruct v0.3</option>
<option value="mistralai/Mixtral-8x7B-Instruct-v0.1">Mixtral 8x7B Instruct</option>
</optgroup>
<optgroup label="Microsoft">
<option value="microsoft/Phi-3-mini-4k-instruct">Phi-3 Mini 4K Instruct</option>
<option value="microsoft/Phi-3.5-mini-instruct">Phi-3.5 Mini Instruct</option>
</optgroup>
<optgroup label="Google">
<option value="google/gemma-2-2b-it">Gemma 2 2B IT</option>
<option value="google/gemma-2-9b-it">Gemma 2 9B IT</option>
</optgroup>
<optgroup label="Qwen">
<option value="Qwen/Qwen2.5-7B-Instruct">Qwen2.5 7B Instruct</option>
<option value="Qwen/Qwen2.5-3B-Instruct">Qwen2.5 3B Instruct</option>
</optgroup>
</select>
<button class="btn btn-primary" style="width:100%" onclick="getAIAdvice()">
✦ Get AI Posture Advice
</button>
<div class="ai-response-box" id="aiResponseBox">
Start your camera session, then click "Get AI Posture Advice" to receive personalized recommendations based on your posture data.
</div>
<div class="ai-disclaimer">
⚠️ <strong>AI Recommendation</strong> — This is generated by an AI language model via Hugging Face Inference API and is not a substitute for professional medical advice.
</div>
</div>
<!-- Log panel -->
<div class="tab-panel" id="tab-log">
<div class="section-title">Session event log</div>
<div class="notif-feed" id="logFeed">
<div class="empty-state">Events will appear here as your session runs.</div>
</div>
</div>
</div>
</div>
<!-- Audio context for alarm -->
<script>
// ═══════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════
const state = {
running: false,
goodFrames: 0,
badFrames: 0,
totalFrames: 0,
currentPose: 'idle', // idle | good | bad
badStreak: 0,
longestBadStreak: 0,
sessionStart: null,
lastAlertTime: 0,
badAlertCount: 0,
timelineData: [], // 'good'|'bad'|'idle'
angleHistory: [],
poseHistory: [], // for log
camera: null,
pose: null,
animFrame: null
};
let videoEl, canvasEl, ctx;
// ═══════════════════════════════════════════════
// DOM ready
// ═══════════════════════════════════════════════
document.addEventListener('DOMContentLoaded', () => {
videoEl = document.getElementById('videoElement');
canvasEl = document.getElementById('canvasElement');
ctx = canvasEl.getContext('2d');
renderTimeline();
renderSparkline();
// Request notification permission on toggle
document.getElementById('toggleNotif').addEventListener('change', async function() {
if (this.checked && Notification.permission === 'default') {
const perm = await Notification.requestPermission();
if (perm !== 'granted') { this.checked = false; showToast('Notification permission denied', 'info'); }
}
});
});
// ═══════════════════════════════════════════════
// TAB SWITCHING
// ═══════════════════════════════════════════════
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t,i) => t.classList.toggle('active', ['alerts','ai','log'][i] === name));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === 'tab-'+name));
}
// ═══════════════════════════════════════════════
// CAMERA & MEDIAPIPE
// ═══════════════════════════════════════════════
async function startCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720, facingMode: 'user' } });
videoEl.srcObject = stream;
await videoEl.play();
canvasEl.width = videoEl.videoWidth || 1280;
canvasEl.height = videoEl.videoHeight || 720;
document.getElementById('startOverlay').style.display = 'none';
document.getElementById('startBtn').disabled = true;
document.getElementById('stopBtn').disabled = false;
document.getElementById('angleDisplay').style.display = 'flex';
state.running = true;
state.sessionStart = Date.now();
const statusPill = document.getElementById('statusPill');
statusPill.classList.add('active');
document.getElementById('statusText').textContent = 'Analyzing…';
initMediaPipe(stream);
startSessionTimer();
logEvent('Session started', 'info');
showToast('Camera started — stand in front of the camera', 'info');
} catch(e) {
showToast('Camera error: ' + e.message, 'bad');
}
}
function initMediaPipe(stream) {
const pose = new Pose({
locateFile: f => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${f}`
});
pose.setOptions({
modelComplexity: 1,
smoothLandmarks: true,
enableSegmentation: false,
minDetectionConfidence: 0.6,
minTrackingConfidence: 0.6
});
pose.onResults(onPoseResults);
state.pose = pose;
const camera = new Camera(videoEl, {
onFrame: async () => {
if (state.running) await pose.send({ image: videoEl });
},
width: 1280, height: 720
});
camera.start();
state.camera = camera;
}
function stopCamera() {
state.running = false;
if (state.camera) state.camera.stop();
if (videoEl.srcObject) videoEl.srcObject.getTracks().forEach(t => t.stop());
document.getElementById('startOverlay').style.display = 'flex';
document.getElementById('startBtn').disabled = false;
document.getElementById('stopBtn').disabled = true;
document.getElementById('statusPill').classList.remove('active','bad-pose','good-pose');
document.getElementById('statusText').textContent = 'Camera off';
document.getElementById('poseBadge').className = 'pose-badge idle';
document.getElementById('poseBadge').textContent = 'Session ended';
document.getElementById('proximityBadge').style.display = 'none';
logEvent('Session stopped', 'info');
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
}
function resetSession() {
Object.assign(state, {
goodFrames:0, badFrames:0, totalFrames:0,
currentPose:'idle', badStreak:0, longestBadStreak:0,
sessionStart: state.running ? Date.now() : null,
lastAlertTime:0, badAlertCount:0,
timelineData:[], angleHistory:[], poseHistory:[]
});
document.getElementById('goodCount').textContent = '0';
document.getElementById('badCount').textContent = '0';
document.getElementById('streakCount').textContent = '0';
document.getElementById('longestStreak').textContent = '0';
document.getElementById('goodPct').textContent = '0% of session';
document.getElementById('badPct').textContent = '0% of session';
document.getElementById('progressGood').style.width = '0%';
document.getElementById('progressBad').style.width = '0%';
document.getElementById('progressGoodLabel').textContent = '';
document.getElementById('progressBadLabel').textContent = '';
document.getElementById('notifFeed').innerHTML = '<div class="empty-state">No alerts yet.</div>';
document.getElementById('logFeed').innerHTML = '<div class="empty-state">Events will appear here.</div>';
renderTimeline();
renderSparkline();
showToast('Session reset', 'info');
}
// ═══════════════════════════════════════════════
// POSE ANALYSIS
// ═══════════════════════════════════════════════
function onPoseResults(results) {
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
if (!results.poseLandmarks) {
setPose('idle');
return;
}
drawConnectors(ctx, results.poseLandmarks, POSE_CONNECTIONS, { color: 'rgba(99,102,241,0.5)', lineWidth: 2 });
drawLandmarks(ctx, results.poseLandmarks, { color: '#6366f1', lineWidth: 1, radius: 4 });
const lm = results.poseLandmarks;
// Key landmarks
const nose = lm[0];
const leftShoulder = lm[11];
const rightShoulder = lm[12];
const leftEar = lm[7];
const rightEar = lm[8];
const leftHip = lm[23];
const rightHip = lm[24];
// 1. Shoulder slope angle
const dx = rightShoulder.x - leftShoulder.x;
const dy = rightShoulder.y - leftShoulder.y;
const shoulderAngle = Math.abs(Math.atan2(dy, dx) * 180 / Math.PI);
// 2. Neck/head tilt: midpoint of ears vs midpoint of shoulders
const earMidX = (leftEar.x + rightEar.x) / 2;
const earMidY = (leftEar.y + rightEar.y) / 2;
const shoulderMidX = (leftShoulder.x + rightShoulder.x) / 2;
const shoulderMidY = (leftShoulder.y + rightShoulder.y) / 2;
const neckAngle = Math.abs(Math.atan2(earMidX - shoulderMidX, shoulderMidY - earMidY) * 180 / Math.PI);
// 3. Torso lean: shoulders vs hips
const hipMidX = (leftHip.x + rightHip.x) / 2;
const hipMidY = (leftHip.y + rightHip.y) / 2;
const torsoAngle = Math.abs(Math.atan2(shoulderMidX - hipMidX, hipMidY - shoulderMidY) * 180 / Math.PI);
// ── 4. Face proximity detection ──────────────────
// Use the inter-eye distance (lm[1]=left eye inner, lm[4]=right eye inner)
// relative to frame width. Larger = closer to camera.
const leftEyeInner = lm[1];
const rightEyeInner = lm[4];
const eyeSpan = Math.abs(rightEyeInner.x - leftEyeInner.x); // normalized 0-1
// eyeSpan > 0.18 → face fills too much of frame → too close
const isTooClose = eyeSpan > 0.18;
const distCm = Math.round(30 / eyeSpan); // rough estimate in cm
document.getElementById('chipDist').textContent = `~${distCm}cm`;
const proximityBadge = document.getElementById('proximityBadge');
proximityBadge.style.display = 'block';
if (isTooClose) {
proximityBadge.className = 'pose-badge bad';
proximityBadge.textContent = '⚠ Too Close!';
} else {
proximityBadge.className = 'pose-badge good';
proximityBadge.textContent = `📏 ~${distCm}cm`;
}
// ── Classify posture ─────────────────────────────
const isSlouchShoulder = shoulderAngle > 8;
const isSlouchNeck = neckAngle > 18;
const isSlouchTorso = torsoAngle > 15;
const slouchScore = (isSlouchShoulder ? 1 : 0) + (isSlouchNeck ? 1 : 0) + (isSlouchTorso ? 1 : 0);
const isGoodPosture = slouchScore <= 1 && !isTooClose;
// Update chips
document.getElementById('chipShoulder').textContent = `Shoulder ${shoulderAngle.toFixed(1)}°`;
document.getElementById('chipNeck').textContent = `Neck ${neckAngle.toFixed(1)}°`;
document.getElementById('chipTilt').textContent = `Torso ${torsoAngle.toFixed(1)}°`;
// Draw color overlays on skeleton
const lineColor = isTooClose ? 'rgba(239,68,68,0.7)' : isGoodPosture ? 'rgba(34,197,94,0.6)' : 'rgba(249,115,22,0.6)';
drawConnectors(ctx, lm, POSE_CONNECTIONS, { color: lineColor, lineWidth: 2 });
drawLandmarks(ctx, lm, { color: isTooClose ? '#ef4444' : isGoodPosture ? '#22c55e' : '#f97316', lineWidth: 1, radius: 5 });
// Draw proximity warning overlay when too close
if (isTooClose) {
ctx.save();
ctx.strokeStyle = 'rgba(239,68,68,0.8)';
ctx.lineWidth = 6;
ctx.strokeRect(3, 3, canvasEl.width - 6, canvasEl.height - 6);
ctx.restore();
}
setPose(isTooClose ? 'tooclose' : isGoodPosture ? 'good' : 'bad');
recordAngle(shoulderAngle);
checkAlarm();
}
function setPose(pose) {
const prev = state.currentPose;
state.currentPose = pose;
state.totalFrames++;
const badge = document.getElementById('poseBadge');
const pill = document.getElementById('statusPill');
if (pose === 'good') {
state.goodFrames++;
state.badStreak = 0;
badge.className = 'pose-badge good';
badge.textContent = '✓ Good Posture';
pill.className = 'status-pill active good-pose';
document.getElementById('statusText').textContent = 'Good posture';
if (prev !== 'good' && prev !== 'idle') logEvent('Posture corrected ✓', 'good');
} else if (pose === 'tooclose') {
state.badFrames++;
state.badStreak++;
if (state.badStreak > state.longestBadStreak) state.longestBadStreak = state.badStreak;
badge.className = 'pose-badge bad';
badge.textContent = '📏 Too Close to Screen!';
pill.className = 'status-pill active bad-pose';
document.getElementById('statusText').textContent = 'Too close — move back';
if (prev !== 'tooclose') logEvent('Too close to screen! Move back ⚠', 'bad');
} else if (pose === 'bad') {
state.badFrames++;
state.badStreak++;
if (state.badStreak > state.longestBadStreak) state.longestBadStreak = state.badStreak;
badge.className = 'pose-badge bad';
badge.textContent = '⚠ Slouching';
pill.className = 'status-pill active bad-pose';
document.getElementById('statusText').textContent = 'Slouching detected';
if (prev !== 'bad') logEvent('Slouching detected ⚠', 'bad');
} else {
badge.className = 'pose-badge idle';
badge.textContent = 'No person detected';
pill.className = 'status-pill active';
document.getElementById('statusText').textContent = 'Searching…';
}
state.poseHistory.push(pose);
updateStats();
// Push to timeline every ~1s (30 frames ≈ 1s)
if (state.totalFrames % 10 === 0) {
state.timelineData.push(pose);
renderTimeline();
}
}
function recordAngle(angle) {
state.angleHistory.push(angle);
if (state.angleHistory.length > 80) state.angleHistory.shift();
renderSparkline();
}
// ═══════════════════════════════════════════════
// STATS UPDATE
// ═══════════════════════════════════════════════
function updateStats() {
const total = state.goodFrames + state.badFrames;
const goodPct = total > 0 ? Math.round(state.goodFrames / total * 100) : 0;
const badPct = 100 - goodPct;
document.getElementById('goodCount').textContent = state.goodFrames;
document.getElementById('badCount').textContent = state.badFrames;
document.getElementById('streakCount').textContent = state.badAlertCount;
document.getElementById('longestStreak').textContent = Math.round(state.longestBadStreak / 30);
document.getElementById('goodPct').textContent = `${goodPct}% of session`;
document.getElementById('badPct').textContent = `${badPct}% of session`;
const gw = goodPct + '%';
const bw = badPct + '%';
document.getElementById('progressGood').style.width = gw;
document.getElementById('progressBad').style.width = bw;
document.getElementById('progressGoodLabel').textContent = goodPct > 15 ? `Good ${goodPct}%` : '';
document.getElementById('progressBadLabel').textContent = badPct > 15 ? `Bad ${badPct}%` : '';
}
// ═══════════════════════════════════════════════
// SESSION TIMER
// ═══════════════════════════════════════════════
function startSessionTimer() {
const tick = () => {
if (!state.running || !state.sessionStart) return;
const s = Math.floor((Date.now() - state.sessionStart) / 1000);
const m = Math.floor(s / 60);
document.getElementById('sessionTime').textContent = `${m}:${String(s % 60).padStart(2,'0')}`;
setTimeout(tick, 1000);
};
tick();
}
// ═══════════════════════════════════════════════
// ALARM / NOTIFICATIONS
// ═══════════════════════════════════════════════
function checkAlarm() {
if (state.currentPose !== 'bad' && state.currentPose !== 'tooclose') return;
const interval = parseInt(document.getElementById('alertInterval').value) * 30; // frames
if (state.badStreak > 0 && state.badStreak % interval === 0) {
state.badAlertCount++;
triggerAlert();
}
}
function triggerAlert() {
const msg = getBadPostureMessage();
addNotification(msg, 'bad');
if (document.getElementById('toggleSound').checked) playBeep();
if (document.getElementById('toggleNotif').checked && Notification.permission === 'granted') {
new Notification('PostureCoach Alert ⚠', { body: msg, icon: '🧘' });
}
showToast(msg, 'bad');
document.getElementById('streakCount').textContent = state.badAlertCount;
}
function getBadPostureMessage() {
if (state.currentPose === 'tooclose') {
const msgs = [
'Move back from the screen — you\'re too close!',
'Screen distance too short — scoot your chair back',
'Your face is very close to the display — move away',
'Protect your eyes — increase screen distance',
];
return msgs[state.badAlertCount % msgs.length];
}
const msgs = [
'Sit up straight — you\'re slouching!',
'Shoulders back, chin up!',
'Check your posture — you\'ve been slouching',
'Straighten your spine!',
'Take a moment to adjust your posture',
'Roll your shoulders back and sit tall',
];
return msgs[state.badAlertCount % msgs.length];
}
function playBeep() {
try {
const ac = new AudioContext();
const osc = ac.createOscillator();
const gain = ac.createGain();
osc.connect(gain);
gain.connect(ac.destination);
osc.type = 'sine';
osc.frequency.setValueAtTime(880, ac.currentTime);
osc.frequency.exponentialRampToValueAtTime(440, ac.currentTime + 0.3);
gain.gain.setValueAtTime(0.4, ac.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.4);
osc.start();
osc.stop(ac.currentTime + 0.4);
} catch(e) {}
}
function addNotification(msg, type) {
const feed = document.getElementById('notifFeed');
const empty = feed.querySelector('.empty-state');
if (empty) empty.remove();
const time = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = `notif-item ${type}`;
div.innerHTML = `<span>${type === 'bad' ? '⚠' : '✓'} ${msg}</span><span class="notif-time">${time}</span>`;
feed.prepend(div);
// Max 20 notifications
const items = feed.querySelectorAll('.notif-item');
if (items.length > 20) items[items.length - 1].remove();
}
function logEvent(msg, type) {
const feed = document.getElementById('logFeed');
const empty = feed.querySelector('.empty-state');
if (empty) empty.remove();
const time = new Date().toLocaleTimeString();
const div = document.createElement('div');
div.className = `notif-item ${type}`;
div.innerHTML = `<span>${msg}</span><span class="notif-time">${time}</span>`;
feed.prepend(div);
const items = feed.querySelectorAll('.notif-item');
if (items.length > 50) items[items.length - 1].remove();
}
// ═══════════════════════════════════════════════
// TOAST
// ═══════════════════════════════════════════════
function showToast(msg, type) {
const c = document.getElementById('toast-container');
const t = document.createElement('div');
t.className = `toast ${type}`;
const icons = { bad: '⚠', good: '✓', info: 'ℹ' };
t.innerHTML = `<span>${icons[type]||'ℹ'}</span><span>${msg}</span>`;
c.prepend(t);
setTimeout(() => { t.style.opacity='0'; t.style.transform='translateX(40px)'; t.style.transition='all 0.3s'; setTimeout(()=>t.remove(),300); }, 4000);
}
// ═══════════════════════════════════════════════
// TIMELINE VISUALIZATION
// ═══════════════════════════════════════════════
function renderTimeline() {
const container = document.getElementById('miniTimeline');
container.innerHTML = '';
const data = state.timelineData.slice(-60); // last 60 points
const maxBars = 60;
const bars = Math.max(data.length, 20);
for (let i = 0; i < bars; i++) {
const d = data[i] || 'idle';
const bar = document.createElement('div');
bar.className = `timeline-bar ${d === 'tooclose' ? 'bad' : d}`;
const h = d === 'good' ? 28 : (d === 'bad' || d === 'tooclose') ? 20 : 6;
if (d === 'tooclose') bar.style.background = '#ef4444';
bar.style.height = h + 'px';
container.appendChild(bar);
}
}
// ═══════════════════════════════════════════════
// SPARKLINE
// ═══════════════════════════════════════════════
function renderSparkline() {
const canvas = document.getElementById('sparkCanvas');
const w = canvas.parentElement.clientWidth - 24;
const h = 70;
canvas.width = w;
canvas.height = h;
const c = canvas.getContext('2d');
c.clearRect(0, 0, w, h);
const data = state.angleHistory;
if (data.length < 2) {
c.fillStyle = 'rgba(99,102,241,0.15)';
c.fillRect(0, 0, w, h);
c.fillStyle = '#6b7280';
c.font = '11px Inter';
c.textAlign = 'center';
c.fillText('Start camera to see angle data', w/2, h/2+4);
return;
}
const maxVal = Math.max(...data, 20);
const minVal = 0;
const range = maxVal - minVal;
// Threshold line at 8 (shoulder threshold)
const thY = h - ((8 - minVal) / range) * h;
c.setLineDash([4, 4]);
c.strokeStyle = 'rgba(249,115,22,0.4)';
c.lineWidth = 1;
c.beginPath();
c.moveTo(0, thY);
c.lineTo(w, thY);
c.stroke();
c.setLineDash([]);
// Area fill
const grad = c.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, 'rgba(99,102,241,0.5)');
grad.addColorStop(1, 'rgba(99,102,241,0.0)');
c.beginPath();
data.forEach((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - ((v - minVal) / range) * h;
i === 0 ? c.moveTo(x, y) : c.lineTo(x, y);
});
c.lineTo(w, h);
c.lineTo(0, h);
c.closePath();
c.fillStyle = grad;
c.fill();
// Line
c.beginPath();
data.forEach((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - ((v - minVal) / range) * h;
i === 0 ? c.moveTo(x, y) : c.lineTo(x, y);
});
c.strokeStyle = '#6366f1';
c.lineWidth = 2;
c.stroke();
}
// ═══════════════════════════════════════════════
// AI COACH via Hugging Face
// ═══════════════════════════════════════════════
async function getAIAdvice() {
const token = document.getElementById('hfToken').value.trim();
if (!token) {
showToast('Please enter your Hugging Face token', 'info');
switchTab('ai');
return;
}
const model = document.getElementById('modelSelect').value;
const box = document.getElementById('aiResponseBox');
const total = state.goodFrames + state.badFrames;
const goodPct = total > 0 ? Math.round(state.goodFrames / total * 100) : 0;
const badPct = 100 - goodPct;
const avgAngle = state.angleHistory.length
? (state.angleHistory.reduce((a,b)=>a+b,0) / state.angleHistory.length).toFixed(1)
: 'N/A';
const sessionSec = state.sessionStart ? Math.floor((Date.now()-state.sessionStart)/1000) : 0;
const tooCloseAlerts = state.poseHistory.filter(p => p === 'tooclose').length;
const prompt = `You are an expert physiotherapist and posture coach. A user has completed a posture monitoring session with the following data:
Session duration: ${Math.floor(sessionSec/60)}m ${sessionSec%60}s
Good posture: ${state.goodFrames} frames (${goodPct}%)
Bad posture / slouching: ${state.badFrames} frames (${badPct}%)
Too-close-to-screen detections: ${tooCloseAlerts} frames
Bad posture alerts triggered: ${state.badAlertCount}
Longest bad posture streak: ~${Math.round(state.longestBadStreak/30)}s
Average shoulder alignment angle: ${avgAngle}°
Current posture status: ${state.currentPose}
Based on this data, provide:
1. A brief assessment of the user's posture habits (2-3 sentences)
2. 3 specific, actionable exercises or adjustments to improve their posture
3. A workstation tip relevant to their data
4. An encouraging closing statement
Be concise, warm, and practical. Format with clear numbered sections. Do not use markdown headers.`;
box.className = 'ai-response-box loading';
box.innerHTML = '<div class="spinner"></div> Asking ' + model.split('/')[1] + '…';
try {
// Use router.huggingface.co — supports CORS from browser / HF Spaces
const res = await fetch(`https://router.huggingface.co/v1/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: model,
messages: [{ role: 'user', content: prompt }],
max_tokens: 500,
temperature: 0.7,
stream: false
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.error?.message || `HTTP ${res.status}`);
}
const data = await res.json();
const text = data.choices?.[0]?.message?.content || 'No response received.';
box.className = 'ai-response-box';
box.textContent = text;
logEvent(`AI advice fetched from ${model.split('/')[1]}`, 'good');
switchTab('ai');
} catch(e) {
box.className = 'ai-response-box';
box.textContent = `Error: ${e.message}\n\nTips:\n• Make sure your HF token is valid and has read access\n• Some models require a Pro HF subscription\n• Try a smaller model like Phi-3 Mini or Gemma 2 2B`;
showToast('AI request failed: ' + e.message, 'bad');
}
}
</script>
</body>
</html>