Abdurrehman999 commited on
Commit
1d8937d
·
verified ·
1 Parent(s): 09b2386

Upload 2 files

Browse files
Files changed (2) hide show
  1. README.md +45 -0
  2. index.html +1477 -0
README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PostureCoach — Real-Time Posture Analysis
3
+ emoji: 🧘
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: static
7
+ pinned: false
8
+ license: mit
9
+ short_description: Posture classifier with MediaPipe + AI coaching
10
+ ---
11
+
12
+ # 🧘 PostureCoach — Real-Time Posture Analysis
13
+
14
+ A browser-based posture monitoring app that uses **MediaPipe Pose** to classify your posture in real time and provides **AI-powered coaching** via Hugging Face Inference API.
15
+
16
+ ## Features
17
+
18
+ - 📷 **Real-time camera analysis** — MediaPipe detects 33 body landmarks and measures shoulder, neck, and torso angles
19
+ - ✅ **Good Posture vs Slouching classification** — instant visual feedback with color-coded skeleton overlay
20
+ - 📊 **Live dashboard** — tracks good/bad frame counts, session time, alert streaks, and angle sparkline
21
+ - 🔔 **Smart alerts** — configurable sound alarm + browser notifications when bad posture is detected
22
+ - 🤖 **AI Coach** — 10 LLM models via Hugging Face Inference API (Llama, Mistral, Phi-3, Gemma, Qwen)
23
+
24
+ ## How to Use
25
+
26
+ 1. Open the Space in your browser
27
+ 2. Click **▶ Start Camera** and allow camera access
28
+ 3. Sit in frame — posture analysis starts immediately
29
+ 4. Switch to the **AI Coach** tab, paste your HF token, pick a model, and get personalized advice
30
+
31
+ ## AI Models Supported
32
+
33
+ | Provider | Models |
34
+ |----------|--------|
35
+ | Meta | Llama 3.2 3B, Llama 3.1 8B |
36
+ | Mistral | Mistral 7B, Mixtral 8x7B |
37
+ | Microsoft | Phi-3 Mini, Phi-3.5 Mini |
38
+ | Google | Gemma 2 2B, Gemma 2 9B |
39
+ | Qwen | Qwen2.5 3B, Qwen2.5 7B |
40
+
41
+ > ⚠️ **AI Disclaimer**: Recommendations are generated by AI and are not a substitute for professional medical advice.
42
+
43
+ ## Privacy
44
+
45
+ All posture analysis runs **100% in your browser** — no video is sent to any server. Your HF token is used only for direct API calls to Hugging Face from your browser.
index.html ADDED
@@ -0,0 +1,1477 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>PostureCoach — Real-Time Posture Analysis</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/pose/pose.js" crossorigin="anonymous"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
9
+ <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
10
+ <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">
11
+ <style>
12
+ :root {
13
+ --bg: #0d0f14;
14
+ --surface: #141720;
15
+ --surface2: #1c2030;
16
+ --border: #252a3a;
17
+ --good: #22c55e;
18
+ --good-dim: rgba(34,197,94,0.12);
19
+ --bad: #f97316;
20
+ --bad-dim: rgba(249,115,22,0.12);
21
+ --accent: #6366f1;
22
+ --accent-dim: rgba(99,102,241,0.15);
23
+ --text: #e8eaf0;
24
+ --text-muted: #6b7280;
25
+ --text-dim: #9ca3af;
26
+ --radius: 12px;
27
+ --radius-sm: 8px;
28
+ }
29
+
30
+ * { box-sizing: border-box; margin: 0; padding: 0; }
31
+
32
+ body {
33
+ font-family: 'Inter', sans-serif;
34
+ background: var(--bg);
35
+ color: var(--text);
36
+ min-height: 100vh;
37
+ overflow-x: hidden;
38
+ }
39
+
40
+ /* ── Header ── */
41
+ header {
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: space-between;
45
+ padding: 18px 28px;
46
+ border-bottom: 1px solid var(--border);
47
+ background: var(--surface);
48
+ position: sticky;
49
+ top: 0;
50
+ z-index: 100;
51
+ }
52
+
53
+ .logo {
54
+ display: flex;
55
+ align-items: center;
56
+ gap: 10px;
57
+ font-weight: 700;
58
+ font-size: 1.15rem;
59
+ letter-spacing: -0.02em;
60
+ }
61
+ .logo-icon {
62
+ width: 32px; height: 32px;
63
+ background: linear-gradient(135deg, var(--accent), #a78bfa);
64
+ border-radius: 8px;
65
+ display: flex; align-items: center; justify-content: center;
66
+ font-size: 16px;
67
+ }
68
+
69
+ .header-right {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 14px;
73
+ }
74
+
75
+ .status-pill {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 7px;
79
+ padding: 6px 14px;
80
+ border-radius: 20px;
81
+ font-size: 0.78rem;
82
+ font-weight: 600;
83
+ background: var(--surface2);
84
+ border: 1px solid var(--border);
85
+ transition: all 0.3s;
86
+ }
87
+ .status-dot {
88
+ width: 8px; height: 8px;
89
+ border-radius: 50%;
90
+ background: var(--text-muted);
91
+ transition: all 0.3s;
92
+ }
93
+ .status-pill.active .status-dot { background: var(--good); box-shadow: 0 0 8px var(--good); animation: pulse 1.5s infinite; }
94
+ .status-pill.bad-pose { background: var(--bad-dim); border-color: var(--bad); }
95
+ .status-pill.bad-pose .status-dot { background: var(--bad); box-shadow: 0 0 8px var(--bad); }
96
+ .status-pill.good-pose { background: var(--good-dim); border-color: var(--good); }
97
+ .status-pill.good-pose .status-dot { background: var(--good); }
98
+
99
+ @keyframes pulse {
100
+ 0%,100% { opacity: 1; } 50% { opacity: 0.4; }
101
+ }
102
+
103
+ /* ── Layout ── */
104
+ .app {
105
+ display: grid;
106
+ grid-template-columns: 1fr 360px;
107
+ gap: 0;
108
+ min-height: calc(100vh - 65px);
109
+ }
110
+
111
+ /* ── Main camera area ── */
112
+ .camera-panel {
113
+ padding: 24px;
114
+ display: flex;
115
+ flex-direction: column;
116
+ gap: 20px;
117
+ }
118
+
119
+ .camera-container {
120
+ position: relative;
121
+ border-radius: var(--radius);
122
+ overflow: hidden;
123
+ background: #000;
124
+ border: 1px solid var(--border);
125
+ aspect-ratio: 16/9;
126
+ max-height: 420px;
127
+ }
128
+
129
+ #videoElement {
130
+ width: 100%; height: 100%;
131
+ object-fit: cover;
132
+ display: block;
133
+ transform: scaleX(-1);
134
+ }
135
+
136
+ #canvasElement {
137
+ position: absolute;
138
+ top: 0; left: 0;
139
+ width: 100%; height: 100%;
140
+ transform: scaleX(-1);
141
+ }
142
+
143
+ .camera-overlay {
144
+ position: absolute;
145
+ top: 0; left: 0;
146
+ width: 100%; height: 100%;
147
+ display: flex;
148
+ flex-direction: column;
149
+ justify-content: space-between;
150
+ pointer-events: none;
151
+ padding: 16px;
152
+ }
153
+
154
+ .pose-badge {
155
+ align-self: flex-end;
156
+ padding: 8px 16px;
157
+ border-radius: 20px;
158
+ font-size: 0.85rem;
159
+ font-weight: 700;
160
+ backdrop-filter: blur(12px);
161
+ border: 2px solid transparent;
162
+ transition: all 0.3s;
163
+ letter-spacing: 0.02em;
164
+ }
165
+ .pose-badge.good { background: rgba(34,197,94,0.85); color: #fff; border-color: var(--good); }
166
+ .pose-badge.bad { background: rgba(249,115,22,0.85); color: #fff; border-color: var(--bad); animation: shake 0.4s; }
167
+ .pose-badge.idle { background: rgba(0,0,0,0.6); color: var(--text-dim); border-color: var(--border); }
168
+
169
+ @keyframes shake {
170
+ 0%,100%{transform:translateX(0)} 25%{transform:translateX(-4px)} 75%{transform:translateX(4px)}
171
+ }
172
+
173
+ .angle-display {
174
+ display: flex;
175
+ gap: 10px;
176
+ }
177
+ .angle-chip {
178
+ padding: 5px 10px;
179
+ border-radius: 8px;
180
+ background: rgba(0,0,0,0.7);
181
+ backdrop-filter: blur(8px);
182
+ font-family: 'JetBrains Mono', monospace;
183
+ font-size: 0.7rem;
184
+ color: var(--text-dim);
185
+ border: 1px solid rgba(255,255,255,0.1);
186
+ }
187
+
188
+ /* ── Camera controls ── */
189
+ .controls {
190
+ display: flex;
191
+ gap: 12px;
192
+ align-items: center;
193
+ }
194
+
195
+ .btn {
196
+ display: inline-flex;
197
+ align-items: center;
198
+ gap: 8px;
199
+ padding: 10px 20px;
200
+ border-radius: var(--radius-sm);
201
+ font-size: 0.875rem;
202
+ font-weight: 600;
203
+ font-family: inherit;
204
+ cursor: pointer;
205
+ border: none;
206
+ transition: all 0.2s;
207
+ }
208
+ .btn-primary {
209
+ background: var(--accent);
210
+ color: #fff;
211
+ }
212
+ .btn-primary:hover { background: #4f52e0; transform: translateY(-1px); }
213
+ .btn-secondary {
214
+ background: var(--surface2);
215
+ color: var(--text);
216
+ border: 1px solid var(--border);
217
+ }
218
+ .btn-secondary:hover { background: var(--border); }
219
+ .btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none !important; }
220
+
221
+ /* ── Stats row ── */
222
+ .stats-row {
223
+ display: grid;
224
+ grid-template-columns: repeat(4, 1fr);
225
+ gap: 14px;
226
+ }
227
+
228
+ .stat-card {
229
+ background: var(--surface);
230
+ border: 1px solid var(--border);
231
+ border-radius: var(--radius);
232
+ padding: 16px;
233
+ display: flex;
234
+ flex-direction: column;
235
+ gap: 4px;
236
+ }
237
+ .stat-label {
238
+ font-size: 0.72rem;
239
+ color: var(--text-muted);
240
+ font-weight: 500;
241
+ text-transform: uppercase;
242
+ letter-spacing: 0.05em;
243
+ }
244
+ .stat-value {
245
+ font-size: 1.6rem;
246
+ font-weight: 700;
247
+ font-family: 'JetBrains Mono', monospace;
248
+ line-height: 1;
249
+ }
250
+ .stat-value.good { color: var(--good); }
251
+ .stat-value.bad { color: var(--bad); }
252
+ .stat-value.accent { color: var(--accent); }
253
+ .stat-sub {
254
+ font-size: 0.72rem;
255
+ color: var(--text-muted);
256
+ margin-top: 2px;
257
+ }
258
+
259
+ /* ── Session bar chart ── */
260
+ .session-chart {
261
+ background: var(--surface);
262
+ border: 1px solid var(--border);
263
+ border-radius: var(--radius);
264
+ padding: 20px;
265
+ }
266
+ .chart-title {
267
+ font-size: 0.8rem;
268
+ font-weight: 600;
269
+ color: var(--text-dim);
270
+ margin-bottom: 14px;
271
+ }
272
+ .progress-track {
273
+ height: 28px;
274
+ background: var(--surface2);
275
+ border-radius: 6px;
276
+ overflow: hidden;
277
+ position: relative;
278
+ display: flex;
279
+ }
280
+ .progress-good {
281
+ height: 100%;
282
+ background: linear-gradient(90deg, #22c55e, #4ade80);
283
+ transition: width 0.6s ease;
284
+ display: flex;
285
+ align-items: center;
286
+ padding-left: 10px;
287
+ font-size: 0.72rem;
288
+ font-weight: 700;
289
+ color: #052e16;
290
+ min-width: 0;
291
+ overflow: hidden;
292
+ white-space: nowrap;
293
+ }
294
+ .progress-bad {
295
+ height: 100%;
296
+ background: linear-gradient(90deg, #f97316, #fb923c);
297
+ transition: width 0.6s ease;
298
+ display: flex;
299
+ align-items: center;
300
+ padding-left: 10px;
301
+ font-size: 0.72rem;
302
+ font-weight: 700;
303
+ color: #431407;
304
+ min-width: 0;
305
+ overflow: hidden;
306
+ white-space: nowrap;
307
+ }
308
+
309
+ .mini-timeline {
310
+ display: flex;
311
+ gap: 3px;
312
+ margin-top: 12px;
313
+ height: 32px;
314
+ align-items: flex-end;
315
+ }
316
+ .timeline-bar {
317
+ flex: 1;
318
+ border-radius: 3px 3px 0 0;
319
+ min-height: 4px;
320
+ transition: height 0.3s;
321
+ }
322
+ .timeline-bar.good { background: var(--good); opacity: 0.7; }
323
+ .timeline-bar.bad { background: var(--bad); opacity: 0.7; }
324
+ .timeline-bar.idle { background: var(--border); }
325
+
326
+ /* ── Sidebar ── */
327
+ .sidebar {
328
+ border-left: 1px solid var(--border);
329
+ background: var(--surface);
330
+ display: flex;
331
+ flex-direction: column;
332
+ overflow: hidden;
333
+ }
334
+
335
+ .sidebar-tabs {
336
+ display: flex;
337
+ border-bottom: 1px solid var(--border);
338
+ }
339
+ .tab {
340
+ flex: 1;
341
+ padding: 14px 10px;
342
+ text-align: center;
343
+ font-size: 0.78rem;
344
+ font-weight: 600;
345
+ color: var(--text-muted);
346
+ cursor: pointer;
347
+ border-bottom: 2px solid transparent;
348
+ transition: all 0.2s;
349
+ background: none;
350
+ border-top: none;
351
+ border-left: none;
352
+ border-right: none;
353
+ font-family: inherit;
354
+ }
355
+ .tab.active {
356
+ color: var(--accent);
357
+ border-bottom-color: var(--accent);
358
+ background: var(--accent-dim);
359
+ }
360
+
361
+ .tab-panel {
362
+ display: none;
363
+ flex: 1;
364
+ overflow-y: auto;
365
+ padding: 16px;
366
+ flex-direction: column;
367
+ gap: 12px;
368
+ }
369
+ .tab-panel.active { display: flex; }
370
+
371
+ /* ── Notifications ── */
372
+ .alert-settings {
373
+ background: var(--surface2);
374
+ border-radius: var(--radius);
375
+ padding: 14px;
376
+ border: 1px solid var(--border);
377
+ }
378
+ .alert-title {
379
+ font-size: 0.8rem;
380
+ font-weight: 600;
381
+ margin-bottom: 10px;
382
+ color: var(--text-dim);
383
+ }
384
+ .toggle-row {
385
+ display: flex;
386
+ align-items: center;
387
+ justify-content: space-between;
388
+ padding: 8px 0;
389
+ border-bottom: 1px solid var(--border);
390
+ font-size: 0.82rem;
391
+ }
392
+ .toggle-row:last-child { border-bottom: none; }
393
+ .toggle {
394
+ position: relative;
395
+ width: 36px; height: 20px;
396
+ }
397
+ .toggle input { opacity: 0; width: 0; height: 0; }
398
+ .slider {
399
+ position: absolute;
400
+ cursor: pointer;
401
+ inset: 0;
402
+ background: var(--border);
403
+ border-radius: 20px;
404
+ transition: 0.3s;
405
+ }
406
+ .slider:before {
407
+ content: '';
408
+ position: absolute;
409
+ height: 14px; width: 14px;
410
+ left: 3px; bottom: 3px;
411
+ background: white;
412
+ border-radius: 50%;
413
+ transition: 0.3s;
414
+ }
415
+ input:checked + .slider { background: var(--accent); }
416
+ input:checked + .slider:before { transform: translateX(16px); }
417
+
418
+ .notif-feed {
419
+ display: flex;
420
+ flex-direction: column;
421
+ gap: 8px;
422
+ }
423
+
424
+ .notif-item {
425
+ padding: 10px 12px;
426
+ border-radius: var(--radius-sm);
427
+ font-size: 0.78rem;
428
+ display: flex;
429
+ flex-direction: column;
430
+ gap: 3px;
431
+ animation: slideIn 0.3s ease;
432
+ border-left: 3px solid transparent;
433
+ }
434
+ .notif-item.bad {
435
+ background: var(--bad-dim);
436
+ border-left-color: var(--bad);
437
+ }
438
+ .notif-item.good {
439
+ background: var(--good-dim);
440
+ border-left-color: var(--good);
441
+ }
442
+ .notif-time { font-size: 0.68rem; color: var(--text-muted); font-family: 'JetBrains Mono', monospace; }
443
+
444
+ @keyframes slideIn {
445
+ from { opacity: 0; transform: translateX(20px); }
446
+ to { opacity: 1; transform: translateX(0); }
447
+ }
448
+
449
+ /* ── AI Panel ── */
450
+ .ai-header {
451
+ display: flex;
452
+ align-items: center;
453
+ justify-content: space-between;
454
+ margin-bottom: 4px;
455
+ }
456
+ .ai-badge {
457
+ font-size: 0.65rem;
458
+ padding: 3px 8px;
459
+ background: var(--accent-dim);
460
+ color: var(--accent);
461
+ border-radius: 10px;
462
+ font-weight: 600;
463
+ border: 1px solid rgba(99,102,241,0.3);
464
+ }
465
+
466
+ .model-select {
467
+ width: 100%;
468
+ background: var(--surface2);
469
+ border: 1px solid var(--border);
470
+ color: var(--text);
471
+ padding: 9px 12px;
472
+ border-radius: var(--radius-sm);
473
+ font-size: 0.8rem;
474
+ font-family: inherit;
475
+ cursor: pointer;
476
+ margin-bottom: 4px;
477
+ }
478
+
479
+ .hf-token-input {
480
+ width: 100%;
481
+ background: var(--surface2);
482
+ border: 1px solid var(--border);
483
+ color: var(--text);
484
+ padding: 9px 12px;
485
+ border-radius: var(--radius-sm);
486
+ font-size: 0.8rem;
487
+ font-family: 'JetBrains Mono', monospace;
488
+ margin-bottom: 4px;
489
+ outline: none;
490
+ }
491
+ .hf-token-input:focus { border-color: var(--accent); }
492
+ .hf-token-input::placeholder { color: var(--text-muted); font-family: inherit; }
493
+
494
+ .input-label {
495
+ font-size: 0.72rem;
496
+ color: var(--text-muted);
497
+ margin-bottom: 4px;
498
+ display: block;
499
+ }
500
+
501
+ .ai-response-box {
502
+ background: var(--surface2);
503
+ border: 1px solid var(--border);
504
+ border-radius: var(--radius);
505
+ padding: 14px;
506
+ min-height: 120px;
507
+ font-size: 0.8rem;
508
+ line-height: 1.6;
509
+ color: var(--text-dim);
510
+ flex: 1;
511
+ overflow-y: auto;
512
+ white-space: pre-wrap;
513
+ }
514
+ .ai-response-box.loading {
515
+ display: flex;
516
+ align-items: center;
517
+ gap: 10px;
518
+ color: var(--accent);
519
+ }
520
+ .ai-disclaimer {
521
+ font-size: 0.68rem;
522
+ color: var(--text-muted);
523
+ margin-top: 6px;
524
+ padding: 8px 10px;
525
+ background: var(--surface2);
526
+ border-radius: 6px;
527
+ border-left: 2px solid var(--accent);
528
+ }
529
+
530
+ .spinner {
531
+ width: 16px; height: 16px;
532
+ border: 2px solid var(--accent-dim);
533
+ border-top-color: var(--accent);
534
+ border-radius: 50%;
535
+ animation: spin 0.8s linear infinite;
536
+ flex-shrink: 0;
537
+ }
538
+ @keyframes spin { to { transform: rotate(360deg); } }
539
+
540
+ /* ── Toast ── */
541
+ #toast-container {
542
+ position: fixed;
543
+ top: 80px;
544
+ right: 20px;
545
+ z-index: 9999;
546
+ display: flex;
547
+ flex-direction: column;
548
+ gap: 10px;
549
+ pointer-events: none;
550
+ }
551
+ .toast {
552
+ padding: 12px 18px;
553
+ border-radius: var(--radius-sm);
554
+ font-size: 0.82rem;
555
+ font-weight: 600;
556
+ max-width: 300px;
557
+ pointer-events: auto;
558
+ animation: toastIn 0.3s ease;
559
+ display: flex;
560
+ align-items: center;
561
+ gap: 10px;
562
+ box-shadow: 0 4px 20px rgba(0,0,0,0.4);
563
+ }
564
+ .toast.bad { background: #7c2d12; color: #fed7aa; border: 1px solid var(--bad); }
565
+ .toast.good { background: #052e16; color: #bbf7d0; border: 1px solid var(--good); }
566
+ .toast.info { background: #1e1b4b; color: #c7d2fe; border: 1px solid var(--accent); }
567
+ @keyframes toastIn {
568
+ from { opacity: 0; transform: translateX(40px); }
569
+ to { opacity: 1; transform: translateX(0); }
570
+ }
571
+
572
+ /* ── History chart sparkline ── */
573
+ .angle-chart {
574
+ background: var(--surface2);
575
+ border-radius: var(--radius-sm);
576
+ padding: 12px;
577
+ border: 1px solid var(--border);
578
+ margin-top: 4px;
579
+ }
580
+ .angle-chart canvas {
581
+ width: 100% !important;
582
+ height: 70px !important;
583
+ display: block;
584
+ }
585
+ .chart-labels {
586
+ display: flex;
587
+ justify-content: space-between;
588
+ font-size: 0.65rem;
589
+ color: var(--text-muted);
590
+ margin-top: 4px;
591
+ font-family: 'JetBrains Mono', monospace;
592
+ }
593
+
594
+ /* Start screen */
595
+ .start-overlay {
596
+ position: absolute;
597
+ inset: 0;
598
+ background: rgba(13,15,20,0.92);
599
+ display: flex;
600
+ flex-direction: column;
601
+ align-items: center;
602
+ justify-content: center;
603
+ gap: 12px;
604
+ z-index: 10;
605
+ border-radius: var(--radius);
606
+ backdrop-filter: blur(4px);
607
+ }
608
+ .start-overlay h2 { font-size: 1.2rem; font-weight: 700; }
609
+ .start-overlay p { font-size: 0.82rem; color: var(--text-muted); text-align: center; max-width: 220px; line-height: 1.5; }
610
+ .camera-icon { font-size: 48px; opacity: 0.6; }
611
+
612
+ .section-title {
613
+ font-size: 0.72rem;
614
+ font-weight: 600;
615
+ color: var(--text-muted);
616
+ text-transform: uppercase;
617
+ letter-spacing: 0.06em;
618
+ margin-top: 4px;
619
+ margin-bottom: 2px;
620
+ }
621
+
622
+ /* Skeleton */
623
+ .empty-state {
624
+ text-align: center;
625
+ padding: 30px 10px;
626
+ color: var(--text-muted);
627
+ font-size: 0.8rem;
628
+ }
629
+
630
+ /* scrollbar */
631
+ ::-webkit-scrollbar { width: 5px; }
632
+ ::-webkit-scrollbar-track { background: transparent; }
633
+ ::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
634
+
635
+ @media (max-width: 900px) {
636
+ .app { grid-template-columns: 1fr; }
637
+ .sidebar { border-left: none; border-top: 1px solid var(--border); min-height: 500px; }
638
+ .stats-row { grid-template-columns: repeat(2,1fr); }
639
+ }
640
+ </style>
641
+ </head>
642
+ <body>
643
+
644
+ <!-- Header -->
645
+ <header>
646
+ <div class="logo">
647
+ <div class="logo-icon">🧘</div>
648
+ PostureCoach
649
+ </div>
650
+ <div class="header-right">
651
+ <div class="status-pill" id="statusPill">
652
+ <div class="status-dot"></div>
653
+ <span id="statusText">Camera off</span>
654
+ </div>
655
+ </div>
656
+ </header>
657
+
658
+ <!-- Toast container -->
659
+ <div id="toast-container"></div>
660
+
661
+ <!-- Main app -->
662
+ <div class="app">
663
+
664
+ <!-- Left: camera + stats -->
665
+ <div class="camera-panel">
666
+
667
+ <!-- Camera -->
668
+ <div class="camera-container" id="cameraContainer">
669
+ <video id="videoElement" autoplay muted playsinline></video>
670
+ <canvas id="canvasElement"></canvas>
671
+
672
+ <div class="camera-overlay">
673
+ <div style="display:flex;gap:8px;align-self:flex-end;flex-direction:column;align-items:flex-end">
674
+ <div class="pose-badge idle" id="poseBadge">Waiting…</div>
675
+ <div class="pose-badge idle" id="proximityBadge" style="display:none;font-size:0.75rem">📏 Distance OK</div>
676
+ </div>
677
+ <div class="angle-display" id="angleDisplay" style="display:none">
678
+ <div class="angle-chip" id="chipShoulder">Shoulders —</div>
679
+ <div class="angle-chip" id="chipNeck">Neck —</div>
680
+ <div class="angle-chip" id="chipTilt">Torso —</div>
681
+ <div class="angle-chip" id="chipDist">Dist —</div>
682
+ </div>
683
+ </div>
684
+
685
+ <div class="start-overlay" id="startOverlay">
686
+ <div class="camera-icon">📷</div>
687
+ <h2>Start your session</h2>
688
+ <p>Enable your camera to begin real-time posture analysis</p>
689
+ </div>
690
+ </div>
691
+
692
+ <!-- Controls -->
693
+ <div class="controls">
694
+ <button class="btn btn-primary" id="startBtn" onclick="startCamera()">▶ Start Camera</button>
695
+ <button class="btn btn-secondary" id="stopBtn" onclick="stopCamera()" disabled>■ Stop</button>
696
+ <button class="btn btn-secondary" onclick="resetSession()">↺ Reset</button>
697
+ </div>
698
+
699
+ <!-- Stats row -->
700
+ <div class="stats-row">
701
+ <div class="stat-card">
702
+ <div class="stat-label">Good Posture</div>
703
+ <div class="stat-value good" id="goodCount">0</div>
704
+ <div class="stat-sub" id="goodPct">0% of session</div>
705
+ </div>
706
+ <div class="stat-card">
707
+ <div class="stat-label">Bad Posture</div>
708
+ <div class="stat-value bad" id="badCount">0</div>
709
+ <div class="stat-sub" id="badPct">0% of session</div>
710
+ </div>
711
+ <div class="stat-card">
712
+ <div class="stat-label">Bad Streaks</div>
713
+ <div class="stat-value accent" id="streakCount">0</div>
714
+ <div class="stat-sub">Longest: <span id="longestStreak">0</span>s</div>
715
+ </div>
716
+ <div class="stat-card">
717
+ <div class="stat-label">Session Time</div>
718
+ <div class="stat-value accent" id="sessionTime">0:00</div>
719
+ <div class="stat-sub">Active monitoring</div>
720
+ </div>
721
+ </div>
722
+
723
+ <!-- Session bar -->
724
+ <div class="session-chart">
725
+ <div class="chart-title">Session breakdown</div>
726
+ <div class="progress-track">
727
+ <div class="progress-good" id="progressGood" style="width:0%">
728
+ <span id="progressGoodLabel"></span>
729
+ </div>
730
+ <div class="progress-bad" id="progressBad" style="width:0%">
731
+ <span id="progressBadLabel"></span>
732
+ </div>
733
+ </div>
734
+ <div class="mini-timeline" id="miniTimeline">
735
+ <!-- filled dynamically -->
736
+ </div>
737
+ <div class="chart-labels">
738
+ <span>Session start</span>
739
+ <span>Now</span>
740
+ </div>
741
+ </div>
742
+
743
+ <!-- Angle sparkline -->
744
+ <div class="angle-chart">
745
+ <div class="chart-title" style="margin-bottom:8px">Shoulder alignment angle over time</div>
746
+ <canvas id="sparkCanvas" height="70"></canvas>
747
+ <div class="chart-labels"><span>Earlier</span><span>Now</span></div>
748
+ </div>
749
+
750
+ </div>
751
+
752
+ <!-- Sidebar -->
753
+ <div class="sidebar">
754
+ <div class="sidebar-tabs">
755
+ <button class="tab active" onclick="switchTab('alerts')">🔔 Alerts</button>
756
+ <button class="tab" onclick="switchTab('ai')">🤖 AI Coach</button>
757
+ <button class="tab" onclick="switchTab('log')">📋 Log</button>
758
+ </div>
759
+
760
+ <!-- Alerts panel -->
761
+ <div class="tab-panel active" id="tab-alerts">
762
+
763
+ <div class="alert-settings">
764
+ <div class="alert-title">Notification settings</div>
765
+ <div class="toggle-row">
766
+ <span>Sound alarm on bad pose</span>
767
+ <label class="toggle">
768
+ <input type="checkbox" id="toggleSound" checked>
769
+ <span class="slider"></span>
770
+ </label>
771
+ </div>
772
+ <div class="toggle-row">
773
+ <span>Browser notifications</span>
774
+ <label class="toggle">
775
+ <input type="checkbox" id="toggleNotif">
776
+ <span class="slider"></span>
777
+ </label>
778
+ </div>
779
+ <div class="toggle-row">
780
+ <span>Alert every (bad seconds)</span>
781
+ <select class="model-select" id="alertInterval" style="width:90px;margin:0;padding:5px 8px">
782
+ <option value="3">3s</option>
783
+ <option value="5" selected>5s</option>
784
+ <option value="10">10s</option>
785
+ <option value="15">15s</option>
786
+ </select>
787
+ </div>
788
+ </div>
789
+
790
+ <div class="section-title">Recent alerts</div>
791
+ <div class="notif-feed" id="notifFeed">
792
+ <div class="empty-state">No alerts yet. Start the camera.</div>
793
+ </div>
794
+ </div>
795
+
796
+ <!-- AI Coach panel -->
797
+ <div class="tab-panel" id="tab-ai">
798
+
799
+ <div class="ai-header">
800
+ <span class="section-title" style="margin:0">AI Recommendation</span>
801
+ <span class="ai-badge">AI ✦</span>
802
+ </div>
803
+
804
+ <label class="input-label">Hugging Face Token (required)</label>
805
+ <input class="hf-token-input" type="password" id="hfToken"
806
+ placeholder="hf_xxxxxxxxxxxxxxxxxxxx"
807
+ value="">
808
+
809
+ <label class="input-label">Select AI model</label>
810
+ <select class="model-select" id="modelSelect">
811
+ <optgroup label="Meta Llama">
812
+ <option value="meta-llama/Llama-3.2-3B-Instruct">Llama 3.2 3B Instruct</option>
813
+ <option value="meta-llama/Llama-3.1-8B-Instruct">Llama 3.1 8B Instruct</option>
814
+ </optgroup>
815
+ <optgroup label="Mistral">
816
+ <option value="mistralai/Mistral-7B-Instruct-v0.3">Mistral 7B Instruct v0.3</option>
817
+ <option value="mistralai/Mixtral-8x7B-Instruct-v0.1">Mixtral 8x7B Instruct</option>
818
+ </optgroup>
819
+ <optgroup label="Microsoft">
820
+ <option value="microsoft/Phi-3-mini-4k-instruct">Phi-3 Mini 4K Instruct</option>
821
+ <option value="microsoft/Phi-3.5-mini-instruct">Phi-3.5 Mini Instruct</option>
822
+ </optgroup>
823
+ <optgroup label="Google">
824
+ <option value="google/gemma-2-2b-it">Gemma 2 2B IT</option>
825
+ <option value="google/gemma-2-9b-it">Gemma 2 9B IT</option>
826
+ </optgroup>
827
+ <optgroup label="Qwen">
828
+ <option value="Qwen/Qwen2.5-7B-Instruct">Qwen2.5 7B Instruct</option>
829
+ <option value="Qwen/Qwen2.5-3B-Instruct">Qwen2.5 3B Instruct</option>
830
+ </optgroup>
831
+ </select>
832
+
833
+ <button class="btn btn-primary" style="width:100%" onclick="getAIAdvice()">
834
+ ✦ Get AI Posture Advice
835
+ </button>
836
+
837
+ <div class="ai-response-box" id="aiResponseBox">
838
+ Start your camera session, then click "Get AI Posture Advice" to receive personalized recommendations based on your posture data.
839
+ </div>
840
+
841
+ <div class="ai-disclaimer">
842
+ ⚠️ <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.
843
+ </div>
844
+ </div>
845
+
846
+ <!-- Log panel -->
847
+ <div class="tab-panel" id="tab-log">
848
+ <div class="section-title">Session event log</div>
849
+ <div class="notif-feed" id="logFeed">
850
+ <div class="empty-state">Events will appear here as your session runs.</div>
851
+ </div>
852
+ </div>
853
+
854
+ </div>
855
+ </div>
856
+
857
+ <!-- Audio context for alarm -->
858
+ <script>
859
+ // ═══════════════════════════════════════════════
860
+ // STATE
861
+ // ═══════════════════════════════════════════════
862
+ const state = {
863
+ running: false,
864
+ goodFrames: 0,
865
+ badFrames: 0,
866
+ totalFrames: 0,
867
+ currentPose: 'idle', // idle | good | bad
868
+ badStreak: 0,
869
+ longestBadStreak: 0,
870
+ sessionStart: null,
871
+ lastAlertTime: 0,
872
+ badAlertCount: 0,
873
+ timelineData: [], // 'good'|'bad'|'idle'
874
+ angleHistory: [],
875
+ poseHistory: [], // for log
876
+ camera: null,
877
+ pose: null,
878
+ animFrame: null
879
+ };
880
+
881
+ let videoEl, canvasEl, ctx;
882
+
883
+ // ═════════════════���═════════════════════════════
884
+ // DOM ready
885
+ // ═══════════════════════════════════════════════
886
+ document.addEventListener('DOMContentLoaded', () => {
887
+ videoEl = document.getElementById('videoElement');
888
+ canvasEl = document.getElementById('canvasElement');
889
+ ctx = canvasEl.getContext('2d');
890
+ renderTimeline();
891
+ renderSparkline();
892
+
893
+ // Request notification permission on toggle
894
+ document.getElementById('toggleNotif').addEventListener('change', async function() {
895
+ if (this.checked && Notification.permission === 'default') {
896
+ const perm = await Notification.requestPermission();
897
+ if (perm !== 'granted') { this.checked = false; showToast('Notification permission denied', 'info'); }
898
+ }
899
+ });
900
+ });
901
+
902
+ // ═══════════════════════════════════════════════
903
+ // TAB SWITCHING
904
+ // ═══════════════════════════════════════════════
905
+ function switchTab(name) {
906
+ document.querySelectorAll('.tab').forEach((t,i) => t.classList.toggle('active', ['alerts','ai','log'][i] === name));
907
+ document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === 'tab-'+name));
908
+ }
909
+
910
+ // ═══════════════════════════════════════════════
911
+ // CAMERA & MEDIAPIPE
912
+ // ═══════════════════════════════════════════════
913
+ async function startCamera() {
914
+ try {
915
+ const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720, facingMode: 'user' } });
916
+ videoEl.srcObject = stream;
917
+ await videoEl.play();
918
+
919
+ canvasEl.width = videoEl.videoWidth || 1280;
920
+ canvasEl.height = videoEl.videoHeight || 720;
921
+
922
+ document.getElementById('startOverlay').style.display = 'none';
923
+ document.getElementById('startBtn').disabled = true;
924
+ document.getElementById('stopBtn').disabled = false;
925
+ document.getElementById('angleDisplay').style.display = 'flex';
926
+
927
+ state.running = true;
928
+ state.sessionStart = Date.now();
929
+
930
+ const statusPill = document.getElementById('statusPill');
931
+ statusPill.classList.add('active');
932
+ document.getElementById('statusText').textContent = 'Analyzing…';
933
+
934
+ initMediaPipe(stream);
935
+ startSessionTimer();
936
+ logEvent('Session started', 'info');
937
+ showToast('Camera started — stand in front of the camera', 'info');
938
+ } catch(e) {
939
+ showToast('Camera error: ' + e.message, 'bad');
940
+ }
941
+ }
942
+
943
+ function initMediaPipe(stream) {
944
+ const pose = new Pose({
945
+ locateFile: f => `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${f}`
946
+ });
947
+
948
+ pose.setOptions({
949
+ modelComplexity: 1,
950
+ smoothLandmarks: true,
951
+ enableSegmentation: false,
952
+ minDetectionConfidence: 0.6,
953
+ minTrackingConfidence: 0.6
954
+ });
955
+
956
+ pose.onResults(onPoseResults);
957
+ state.pose = pose;
958
+
959
+ const camera = new Camera(videoEl, {
960
+ onFrame: async () => {
961
+ if (state.running) await pose.send({ image: videoEl });
962
+ },
963
+ width: 1280, height: 720
964
+ });
965
+ camera.start();
966
+ state.camera = camera;
967
+ }
968
+
969
+ function stopCamera() {
970
+ state.running = false;
971
+ if (state.camera) state.camera.stop();
972
+ if (videoEl.srcObject) videoEl.srcObject.getTracks().forEach(t => t.stop());
973
+
974
+ document.getElementById('startOverlay').style.display = 'flex';
975
+ document.getElementById('startBtn').disabled = false;
976
+ document.getElementById('stopBtn').disabled = true;
977
+ document.getElementById('statusPill').classList.remove('active','bad-pose','good-pose');
978
+ document.getElementById('statusText').textContent = 'Camera off';
979
+ document.getElementById('poseBadge').className = 'pose-badge idle';
980
+ document.getElementById('poseBadge').textContent = 'Session ended';
981
+ document.getElementById('proximityBadge').style.display = 'none';
982
+ logEvent('Session stopped', 'info');
983
+ ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
984
+ }
985
+
986
+ function resetSession() {
987
+ Object.assign(state, {
988
+ goodFrames:0, badFrames:0, totalFrames:0,
989
+ currentPose:'idle', badStreak:0, longestBadStreak:0,
990
+ sessionStart: state.running ? Date.now() : null,
991
+ lastAlertTime:0, badAlertCount:0,
992
+ timelineData:[], angleHistory:[], poseHistory:[]
993
+ });
994
+ document.getElementById('goodCount').textContent = '0';
995
+ document.getElementById('badCount').textContent = '0';
996
+ document.getElementById('streakCount').textContent = '0';
997
+ document.getElementById('longestStreak').textContent = '0';
998
+ document.getElementById('goodPct').textContent = '0% of session';
999
+ document.getElementById('badPct').textContent = '0% of session';
1000
+ document.getElementById('progressGood').style.width = '0%';
1001
+ document.getElementById('progressBad').style.width = '0%';
1002
+ document.getElementById('progressGoodLabel').textContent = '';
1003
+ document.getElementById('progressBadLabel').textContent = '';
1004
+ document.getElementById('notifFeed').innerHTML = '<div class="empty-state">No alerts yet.</div>';
1005
+ document.getElementById('logFeed').innerHTML = '<div class="empty-state">Events will appear here.</div>';
1006
+ renderTimeline();
1007
+ renderSparkline();
1008
+ showToast('Session reset', 'info');
1009
+ }
1010
+
1011
+ // ═══════════════════════════════════════════════
1012
+ // POSE ANALYSIS
1013
+ // ═══════════════════════════════════════════════
1014
+ function onPoseResults(results) {
1015
+ ctx.clearRect(0, 0, canvasEl.width, canvasEl.height);
1016
+
1017
+ if (!results.poseLandmarks) {
1018
+ setPose('idle');
1019
+ return;
1020
+ }
1021
+
1022
+ drawConnectors(ctx, results.poseLandmarks, POSE_CONNECTIONS, { color: 'rgba(99,102,241,0.5)', lineWidth: 2 });
1023
+ drawLandmarks(ctx, results.poseLandmarks, { color: '#6366f1', lineWidth: 1, radius: 4 });
1024
+
1025
+ const lm = results.poseLandmarks;
1026
+
1027
+ // Key landmarks
1028
+ const nose = lm[0];
1029
+ const leftShoulder = lm[11];
1030
+ const rightShoulder = lm[12];
1031
+ const leftEar = lm[7];
1032
+ const rightEar = lm[8];
1033
+ const leftHip = lm[23];
1034
+ const rightHip = lm[24];
1035
+
1036
+ // 1. Shoulder slope angle
1037
+ const dx = rightShoulder.x - leftShoulder.x;
1038
+ const dy = rightShoulder.y - leftShoulder.y;
1039
+ const shoulderAngle = Math.abs(Math.atan2(dy, dx) * 180 / Math.PI);
1040
+
1041
+ // 2. Neck/head tilt: midpoint of ears vs midpoint of shoulders
1042
+ const earMidX = (leftEar.x + rightEar.x) / 2;
1043
+ const earMidY = (leftEar.y + rightEar.y) / 2;
1044
+ const shoulderMidX = (leftShoulder.x + rightShoulder.x) / 2;
1045
+ const shoulderMidY = (leftShoulder.y + rightShoulder.y) / 2;
1046
+ const neckAngle = Math.abs(Math.atan2(earMidX - shoulderMidX, shoulderMidY - earMidY) * 180 / Math.PI);
1047
+
1048
+ // 3. Torso lean: shoulders vs hips
1049
+ const hipMidX = (leftHip.x + rightHip.x) / 2;
1050
+ const hipMidY = (leftHip.y + rightHip.y) / 2;
1051
+ const torsoAngle = Math.abs(Math.atan2(shoulderMidX - hipMidX, hipMidY - shoulderMidY) * 180 / Math.PI);
1052
+
1053
+ // ── 4. Face proximity detection ──────────────────
1054
+ // Use the inter-eye distance (lm[1]=left eye inner, lm[4]=right eye inner)
1055
+ // relative to frame width. Larger = closer to camera.
1056
+ const leftEyeInner = lm[1];
1057
+ const rightEyeInner = lm[4];
1058
+ const eyeSpan = Math.abs(rightEyeInner.x - leftEyeInner.x); // normalized 0-1
1059
+ // eyeSpan > 0.18 → face fills too much of frame → too close
1060
+ const isTooClose = eyeSpan > 0.18;
1061
+ const distCm = Math.round(30 / eyeSpan); // rough estimate in cm
1062
+ document.getElementById('chipDist').textContent = `~${distCm}cm`;
1063
+
1064
+ const proximityBadge = document.getElementById('proximityBadge');
1065
+ proximityBadge.style.display = 'block';
1066
+ if (isTooClose) {
1067
+ proximityBadge.className = 'pose-badge bad';
1068
+ proximityBadge.textContent = '⚠ Too Close!';
1069
+ } else {
1070
+ proximityBadge.className = 'pose-badge good';
1071
+ proximityBadge.textContent = `📏 ~${distCm}cm`;
1072
+ }
1073
+
1074
+ // ── Classify posture ─────────────────────────────
1075
+ const isSlouchShoulder = shoulderAngle > 8;
1076
+ const isSlouchNeck = neckAngle > 18;
1077
+ const isSlouchTorso = torsoAngle > 15;
1078
+
1079
+ const slouchScore = (isSlouchShoulder ? 1 : 0) + (isSlouchNeck ? 1 : 0) + (isSlouchTorso ? 1 : 0);
1080
+ const isGoodPosture = slouchScore <= 1 && !isTooClose;
1081
+
1082
+ // Update chips
1083
+ document.getElementById('chipShoulder').textContent = `Shoulder ${shoulderAngle.toFixed(1)}°`;
1084
+ document.getElementById('chipNeck').textContent = `Neck ${neckAngle.toFixed(1)}°`;
1085
+ document.getElementById('chipTilt').textContent = `Torso ${torsoAngle.toFixed(1)}°`;
1086
+
1087
+ // Draw color overlays on skeleton
1088
+ const lineColor = isTooClose ? 'rgba(239,68,68,0.7)' : isGoodPosture ? 'rgba(34,197,94,0.6)' : 'rgba(249,115,22,0.6)';
1089
+ drawConnectors(ctx, lm, POSE_CONNECTIONS, { color: lineColor, lineWidth: 2 });
1090
+ drawLandmarks(ctx, lm, { color: isTooClose ? '#ef4444' : isGoodPosture ? '#22c55e' : '#f97316', lineWidth: 1, radius: 5 });
1091
+
1092
+ // Draw proximity warning overlay when too close
1093
+ if (isTooClose) {
1094
+ ctx.save();
1095
+ ctx.strokeStyle = 'rgba(239,68,68,0.8)';
1096
+ ctx.lineWidth = 6;
1097
+ ctx.strokeRect(3, 3, canvasEl.width - 6, canvasEl.height - 6);
1098
+ ctx.restore();
1099
+ }
1100
+
1101
+ setPose(isTooClose ? 'tooclose' : isGoodPosture ? 'good' : 'bad');
1102
+ recordAngle(shoulderAngle);
1103
+ checkAlarm();
1104
+ }
1105
+
1106
+ function setPose(pose) {
1107
+ const prev = state.currentPose;
1108
+ state.currentPose = pose;
1109
+ state.totalFrames++;
1110
+
1111
+ const badge = document.getElementById('poseBadge');
1112
+ const pill = document.getElementById('statusPill');
1113
+
1114
+ if (pose === 'good') {
1115
+ state.goodFrames++;
1116
+ state.badStreak = 0;
1117
+ badge.className = 'pose-badge good';
1118
+ badge.textContent = '✓ Good Posture';
1119
+ pill.className = 'status-pill active good-pose';
1120
+ document.getElementById('statusText').textContent = 'Good posture';
1121
+ if (prev !== 'good' && prev !== 'idle') logEvent('Posture corrected ✓', 'good');
1122
+ } else if (pose === 'tooclose') {
1123
+ state.badFrames++;
1124
+ state.badStreak++;
1125
+ if (state.badStreak > state.longestBadStreak) state.longestBadStreak = state.badStreak;
1126
+ badge.className = 'pose-badge bad';
1127
+ badge.textContent = '📏 Too Close to Screen!';
1128
+ pill.className = 'status-pill active bad-pose';
1129
+ document.getElementById('statusText').textContent = 'Too close — move back';
1130
+ if (prev !== 'tooclose') logEvent('Too close to screen! Move back ⚠', 'bad');
1131
+ } else if (pose === 'bad') {
1132
+ state.badFrames++;
1133
+ state.badStreak++;
1134
+ if (state.badStreak > state.longestBadStreak) state.longestBadStreak = state.badStreak;
1135
+ badge.className = 'pose-badge bad';
1136
+ badge.textContent = '⚠ Slouching';
1137
+ pill.className = 'status-pill active bad-pose';
1138
+ document.getElementById('statusText').textContent = 'Slouching detected';
1139
+ if (prev !== 'bad') logEvent('Slouching detected ⚠', 'bad');
1140
+ } else {
1141
+ badge.className = 'pose-badge idle';
1142
+ badge.textContent = 'No person detected';
1143
+ pill.className = 'status-pill active';
1144
+ document.getElementById('statusText').textContent = 'Searching…';
1145
+ }
1146
+
1147
+ state.poseHistory.push(pose);
1148
+ updateStats();
1149
+
1150
+ // Push to timeline every ~1s (30 frames ≈ 1s)
1151
+ if (state.totalFrames % 10 === 0) {
1152
+ state.timelineData.push(pose);
1153
+ renderTimeline();
1154
+ }
1155
+ }
1156
+
1157
+ function recordAngle(angle) {
1158
+ state.angleHistory.push(angle);
1159
+ if (state.angleHistory.length > 80) state.angleHistory.shift();
1160
+ renderSparkline();
1161
+ }
1162
+
1163
+ // ═══════════════════════════════════════════════
1164
+ // STATS UPDATE
1165
+ // ═══════════════════════════════════════════════
1166
+ function updateStats() {
1167
+ const total = state.goodFrames + state.badFrames;
1168
+ const goodPct = total > 0 ? Math.round(state.goodFrames / total * 100) : 0;
1169
+ const badPct = 100 - goodPct;
1170
+
1171
+ document.getElementById('goodCount').textContent = state.goodFrames;
1172
+ document.getElementById('badCount').textContent = state.badFrames;
1173
+ document.getElementById('streakCount').textContent = state.badAlertCount;
1174
+ document.getElementById('longestStreak').textContent = Math.round(state.longestBadStreak / 30);
1175
+ document.getElementById('goodPct').textContent = `${goodPct}% of session`;
1176
+ document.getElementById('badPct').textContent = `${badPct}% of session`;
1177
+
1178
+ const gw = goodPct + '%';
1179
+ const bw = badPct + '%';
1180
+ document.getElementById('progressGood').style.width = gw;
1181
+ document.getElementById('progressBad').style.width = bw;
1182
+ document.getElementById('progressGoodLabel').textContent = goodPct > 15 ? `Good ${goodPct}%` : '';
1183
+ document.getElementById('progressBadLabel').textContent = badPct > 15 ? `Bad ${badPct}%` : '';
1184
+ }
1185
+
1186
+ // ═══════════════════════════════════════════════
1187
+ // SESSION TIMER
1188
+ // ═══════════════════════════════════════════════
1189
+ function startSessionTimer() {
1190
+ const tick = () => {
1191
+ if (!state.running || !state.sessionStart) return;
1192
+ const s = Math.floor((Date.now() - state.sessionStart) / 1000);
1193
+ const m = Math.floor(s / 60);
1194
+ document.getElementById('sessionTime').textContent = `${m}:${String(s % 60).padStart(2,'0')}`;
1195
+ setTimeout(tick, 1000);
1196
+ };
1197
+ tick();
1198
+ }
1199
+
1200
+ // ═══════════════════════════════════════════════
1201
+ // ALARM / NOTIFICATIONS
1202
+ // ═══════════════════════════════════════════════
1203
+ function checkAlarm() {
1204
+ if (state.currentPose !== 'bad' && state.currentPose !== 'tooclose') return;
1205
+ const interval = parseInt(document.getElementById('alertInterval').value) * 30; // frames
1206
+ if (state.badStreak > 0 && state.badStreak % interval === 0) {
1207
+ state.badAlertCount++;
1208
+ triggerAlert();
1209
+ }
1210
+ }
1211
+
1212
+ function triggerAlert() {
1213
+ const msg = getBadPostureMessage();
1214
+ addNotification(msg, 'bad');
1215
+ if (document.getElementById('toggleSound').checked) playBeep();
1216
+ if (document.getElementById('toggleNotif').checked && Notification.permission === 'granted') {
1217
+ new Notification('PostureCoach Alert ⚠', { body: msg, icon: '🧘' });
1218
+ }
1219
+ showToast(msg, 'bad');
1220
+ document.getElementById('streakCount').textContent = state.badAlertCount;
1221
+ }
1222
+
1223
+ function getBadPostureMessage() {
1224
+ if (state.currentPose === 'tooclose') {
1225
+ const msgs = [
1226
+ 'Move back from the screen — you\'re too close!',
1227
+ 'Screen distance too short — scoot your chair back',
1228
+ 'Your face is very close to the display — move away',
1229
+ 'Protect your eyes — increase screen distance',
1230
+ ];
1231
+ return msgs[state.badAlertCount % msgs.length];
1232
+ }
1233
+ const msgs = [
1234
+ 'Sit up straight — you\'re slouching!',
1235
+ 'Shoulders back, chin up!',
1236
+ 'Check your posture — you\'ve been slouching',
1237
+ 'Straighten your spine!',
1238
+ 'Take a moment to adjust your posture',
1239
+ 'Roll your shoulders back and sit tall',
1240
+ ];
1241
+ return msgs[state.badAlertCount % msgs.length];
1242
+ }
1243
+
1244
+ function playBeep() {
1245
+ try {
1246
+ const ac = new AudioContext();
1247
+ const osc = ac.createOscillator();
1248
+ const gain = ac.createGain();
1249
+ osc.connect(gain);
1250
+ gain.connect(ac.destination);
1251
+ osc.type = 'sine';
1252
+ osc.frequency.setValueAtTime(880, ac.currentTime);
1253
+ osc.frequency.exponentialRampToValueAtTime(440, ac.currentTime + 0.3);
1254
+ gain.gain.setValueAtTime(0.4, ac.currentTime);
1255
+ gain.gain.exponentialRampToValueAtTime(0.001, ac.currentTime + 0.4);
1256
+ osc.start();
1257
+ osc.stop(ac.currentTime + 0.4);
1258
+ } catch(e) {}
1259
+ }
1260
+
1261
+ function addNotification(msg, type) {
1262
+ const feed = document.getElementById('notifFeed');
1263
+ const empty = feed.querySelector('.empty-state');
1264
+ if (empty) empty.remove();
1265
+
1266
+ const time = new Date().toLocaleTimeString();
1267
+ const div = document.createElement('div');
1268
+ div.className = `notif-item ${type}`;
1269
+ div.innerHTML = `<span>${type === 'bad' ? '⚠' : '✓'} ${msg}</span><span class="notif-time">${time}</span>`;
1270
+ feed.prepend(div);
1271
+
1272
+ // Max 20 notifications
1273
+ const items = feed.querySelectorAll('.notif-item');
1274
+ if (items.length > 20) items[items.length - 1].remove();
1275
+ }
1276
+
1277
+ function logEvent(msg, type) {
1278
+ const feed = document.getElementById('logFeed');
1279
+ const empty = feed.querySelector('.empty-state');
1280
+ if (empty) empty.remove();
1281
+
1282
+ const time = new Date().toLocaleTimeString();
1283
+ const div = document.createElement('div');
1284
+ div.className = `notif-item ${type}`;
1285
+ div.innerHTML = `<span>${msg}</span><span class="notif-time">${time}</span>`;
1286
+ feed.prepend(div);
1287
+
1288
+ const items = feed.querySelectorAll('.notif-item');
1289
+ if (items.length > 50) items[items.length - 1].remove();
1290
+ }
1291
+
1292
+ // ═══════════════════════════════════════════════
1293
+ // TOAST
1294
+ // ═══════════════════════════════════════════════
1295
+ function showToast(msg, type) {
1296
+ const c = document.getElementById('toast-container');
1297
+ const t = document.createElement('div');
1298
+ t.className = `toast ${type}`;
1299
+ const icons = { bad: '⚠', good: '✓', info: 'ℹ' };
1300
+ t.innerHTML = `<span>${icons[type]||'ℹ'}</span><span>${msg}</span>`;
1301
+ c.prepend(t);
1302
+ setTimeout(() => { t.style.opacity='0'; t.style.transform='translateX(40px)'; t.style.transition='all 0.3s'; setTimeout(()=>t.remove(),300); }, 4000);
1303
+ }
1304
+
1305
+ // ═══════════════════════════════════════════════
1306
+ // TIMELINE VISUALIZATION
1307
+ // ═══════════════════════════════════════════════
1308
+ function renderTimeline() {
1309
+ const container = document.getElementById('miniTimeline');
1310
+ container.innerHTML = '';
1311
+ const data = state.timelineData.slice(-60); // last 60 points
1312
+ const maxBars = 60;
1313
+ const bars = Math.max(data.length, 20);
1314
+
1315
+ for (let i = 0; i < bars; i++) {
1316
+ const d = data[i] || 'idle';
1317
+ const bar = document.createElement('div');
1318
+ bar.className = `timeline-bar ${d === 'tooclose' ? 'bad' : d}`;
1319
+ const h = d === 'good' ? 28 : (d === 'bad' || d === 'tooclose') ? 20 : 6;
1320
+ if (d === 'tooclose') bar.style.background = '#ef4444';
1321
+ bar.style.height = h + 'px';
1322
+ container.appendChild(bar);
1323
+ }
1324
+ }
1325
+
1326
+ // ═══════════════════════════════════════════════
1327
+ // SPARKLINE
1328
+ // ═══════════════════════════════════════════════
1329
+ function renderSparkline() {
1330
+ const canvas = document.getElementById('sparkCanvas');
1331
+ const w = canvas.parentElement.clientWidth - 24;
1332
+ const h = 70;
1333
+ canvas.width = w;
1334
+ canvas.height = h;
1335
+ const c = canvas.getContext('2d');
1336
+ c.clearRect(0, 0, w, h);
1337
+
1338
+ const data = state.angleHistory;
1339
+ if (data.length < 2) {
1340
+ c.fillStyle = 'rgba(99,102,241,0.15)';
1341
+ c.fillRect(0, 0, w, h);
1342
+ c.fillStyle = '#6b7280';
1343
+ c.font = '11px Inter';
1344
+ c.textAlign = 'center';
1345
+ c.fillText('Start camera to see angle data', w/2, h/2+4);
1346
+ return;
1347
+ }
1348
+
1349
+ const maxVal = Math.max(...data, 20);
1350
+ const minVal = 0;
1351
+ const range = maxVal - minVal;
1352
+
1353
+ // Threshold line at 8 (shoulder threshold)
1354
+ const thY = h - ((8 - minVal) / range) * h;
1355
+ c.setLineDash([4, 4]);
1356
+ c.strokeStyle = 'rgba(249,115,22,0.4)';
1357
+ c.lineWidth = 1;
1358
+ c.beginPath();
1359
+ c.moveTo(0, thY);
1360
+ c.lineTo(w, thY);
1361
+ c.stroke();
1362
+ c.setLineDash([]);
1363
+
1364
+ // Area fill
1365
+ const grad = c.createLinearGradient(0, 0, 0, h);
1366
+ grad.addColorStop(0, 'rgba(99,102,241,0.5)');
1367
+ grad.addColorStop(1, 'rgba(99,102,241,0.0)');
1368
+ c.beginPath();
1369
+ data.forEach((v, i) => {
1370
+ const x = (i / (data.length - 1)) * w;
1371
+ const y = h - ((v - minVal) / range) * h;
1372
+ i === 0 ? c.moveTo(x, y) : c.lineTo(x, y);
1373
+ });
1374
+ c.lineTo(w, h);
1375
+ c.lineTo(0, h);
1376
+ c.closePath();
1377
+ c.fillStyle = grad;
1378
+ c.fill();
1379
+
1380
+ // Line
1381
+ c.beginPath();
1382
+ data.forEach((v, i) => {
1383
+ const x = (i / (data.length - 1)) * w;
1384
+ const y = h - ((v - minVal) / range) * h;
1385
+ i === 0 ? c.moveTo(x, y) : c.lineTo(x, y);
1386
+ });
1387
+ c.strokeStyle = '#6366f1';
1388
+ c.lineWidth = 2;
1389
+ c.stroke();
1390
+ }
1391
+
1392
+ // ═══════════════════════════════════════════════
1393
+ // AI COACH via Hugging Face
1394
+ // ═══════════════════════════════════════════════
1395
+ async function getAIAdvice() {
1396
+ const token = document.getElementById('hfToken').value.trim();
1397
+ if (!token) {
1398
+ showToast('Please enter your Hugging Face token', 'info');
1399
+ switchTab('ai');
1400
+ return;
1401
+ }
1402
+
1403
+ const model = document.getElementById('modelSelect').value;
1404
+ const box = document.getElementById('aiResponseBox');
1405
+
1406
+ const total = state.goodFrames + state.badFrames;
1407
+ const goodPct = total > 0 ? Math.round(state.goodFrames / total * 100) : 0;
1408
+ const badPct = 100 - goodPct;
1409
+ const avgAngle = state.angleHistory.length
1410
+ ? (state.angleHistory.reduce((a,b)=>a+b,0) / state.angleHistory.length).toFixed(1)
1411
+ : 'N/A';
1412
+ const sessionSec = state.sessionStart ? Math.floor((Date.now()-state.sessionStart)/1000) : 0;
1413
+
1414
+ const tooCloseAlerts = state.poseHistory.filter(p => p === 'tooclose').length;
1415
+
1416
+ const prompt = `You are an expert physiotherapist and posture coach. A user has completed a posture monitoring session with the following data:
1417
+
1418
+ Session duration: ${Math.floor(sessionSec/60)}m ${sessionSec%60}s
1419
+ Good posture: ${state.goodFrames} frames (${goodPct}%)
1420
+ Bad posture / slouching: ${state.badFrames} frames (${badPct}%)
1421
+ Too-close-to-screen detections: ${tooCloseAlerts} frames
1422
+ Bad posture alerts triggered: ${state.badAlertCount}
1423
+ Longest bad posture streak: ~${Math.round(state.longestBadStreak/30)}s
1424
+ Average shoulder alignment angle: ${avgAngle}°
1425
+ Current posture status: ${state.currentPose}
1426
+
1427
+ Based on this data, provide:
1428
+ 1. A brief assessment of the user's posture habits (2-3 sentences)
1429
+ 2. 3 specific, actionable exercises or adjustments to improve their posture
1430
+ 3. A workstation tip relevant to their data
1431
+ 4. An encouraging closing statement
1432
+
1433
+ Be concise, warm, and practical. Format with clear numbered sections. Do not use markdown headers.`;
1434
+
1435
+ box.className = 'ai-response-box loading';
1436
+ box.innerHTML = '<div class="spinner"></div> Asking ' + model.split('/')[1] + '…';
1437
+
1438
+ try {
1439
+ // Use router.huggingface.co — supports CORS from browser / HF Spaces
1440
+ const res = await fetch(`https://router.huggingface.co/v1/chat/completions`, {
1441
+ method: 'POST',
1442
+ headers: {
1443
+ 'Authorization': `Bearer ${token}`,
1444
+ 'Content-Type': 'application/json'
1445
+ },
1446
+ body: JSON.stringify({
1447
+ model: model,
1448
+ messages: [{ role: 'user', content: prompt }],
1449
+ max_tokens: 500,
1450
+ temperature: 0.7,
1451
+ stream: false
1452
+ })
1453
+ });
1454
+
1455
+ if (!res.ok) {
1456
+ const err = await res.json().catch(() => ({}));
1457
+ throw new Error(err.error?.message || `HTTP ${res.status}`);
1458
+ }
1459
+
1460
+ const data = await res.json();
1461
+ const text = data.choices?.[0]?.message?.content || 'No response received.';
1462
+
1463
+ box.className = 'ai-response-box';
1464
+ box.textContent = text;
1465
+
1466
+ logEvent(`AI advice fetched from ${model.split('/')[1]}`, 'good');
1467
+ switchTab('ai');
1468
+
1469
+ } catch(e) {
1470
+ box.className = 'ai-response-box';
1471
+ 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`;
1472
+ showToast('AI request failed: ' + e.message, 'bad');
1473
+ }
1474
+ }
1475
+ </script>
1476
+ </body>
1477
+ </html>