/* ========================================================================== FocusPal — AI powered study companion Runs entirely in the browser. Nothing is uploaded. Pipeline: webcam frame -> MediaPipe Face Landmarker (478 points + 52 blendshapes) -> geometry (head yaw / pitch / roll, eye openness, face scale) -> six live parameters, each scored 0-100 -> weighted overall focus score for the whole session ========================================================================== */ import { FaceLandmarker, FilesetResolver } from "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/vision_bundle.mjs"; /* ---------------------------------------------------------------- helpers */ const $ = (s) => document.querySelector(s); const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); const median = (arr) => { if (!arr.length) return 0; const s = [...arr].sort((x, y) => x - y); return s[Math.floor(s.length / 2)]; }; /** how far past "tol" a deviation is, as 0..1 (1 = at or beyond "max") */ const over = (d, tol, max) => clamp((d - tol) / Math.max(max - tol, 1e-6), 0, 1); /** exponential smoothing that is frame-rate independent */ const smooth = (prev, next, dt, tau) => prev + (next - prev) * (1 - Math.exp(-dt / tau)); /* ------------------------------------------------------- landmark indices */ const NOSE = 1, CHIN = 152, BROW = 10, L_FACE = 234, R_FACE = 454; const L_OUT = 33, L_IN = 133, L_UP = 159, L_LO = 145; const R_IN = 362, R_OUT = 263, R_UP = 386, R_LO = 374; /* -------------------------------------------------------- the parameters */ const METERS = [ { id: 'task', name: 'Eyes on the book', hint: 'Looking where you set up' }, { id: 'steady', name: 'No looking away', hint: 'Head not turning aside' }, { id: 'aloud', name: 'Reading aloud', hint: 'Voice heard while reading' }, { id: 'alert', name: 'Alertness', hint: 'Eyes open, not drowsy' }, { id: 'posture', name: 'Posture', hint: 'Sitting up, not slouching' }, { id: 'present', name: 'At the desk', hint: 'Face visible to camera' }, ]; const TASK_NAME = { read: 'Eyes on the book', write: 'Eyes on the page', screen: 'Eyes on the screen', }; /* weight of each parameter in the overall score, per study mode */ const WEIGHTS = { read: { task:.28, steady:.16, aloud:.20, alert:.12, posture:.12, present:.12 }, write: { task:.34, steady:.20, aloud:0, alert:.14, posture:.16, present:.16 }, screen: { task:.32, steady:.22, aloud:0, alert:.16, posture:.14, present:.16 }, }; /* strictness multiplies every tolerance: bigger = more forgiving */ const STRICT = { gentle: 1.5, normal: 1.0, strict: 0.72 }; const FIRM = [ 'Eyes back on your work.', 'You are drifting. Focus now.', 'Stop looking away. Back to studying.', 'Head down. Keep reading.', 'That is a distraction. Return to your book.', ]; const CALM = [ 'Your energy is dipping. Sit up straight.', 'Shoulders back, chin up. You have got this.', 'You look tired. Two minutes of rest will help.', 'Straighten your back. Protect your spine.', ]; const ALOUD_LINES = [ 'I cannot hear you. Read out loud.', 'Use your voice. Reading aloud helps you remember.', ]; /* ---------------------------------------------------------------- DOM refs */ const video = $('#cam'); const canvas = $('#overlay'); const ctx = canvas.getContext('2d'); const veil = $('#veil'); const veilT = $('#veil-title'); const veilS = $('#veil-sub'); const veilC = $('#veil-count'); const nudge = $('#nudge'); const nudgeT = $('#nudge-text'); const statusPill = $('#status-pill'); const statusText = $('#status-text'); const digits = () => document.querySelectorAll('#clock .digit'); const ringFill = $('#ring-fill'); const RING_LEN = 2 * Math.PI * 52; /* ------------------------------------------------------------------ state */ let landmarker = null; let stream = null, audioStream = null; let analyser = null, audioData = null, audioCtx = null; let running = false, phase = 'idle'; // idle | cal1 | cal2 | live let mode = 'read'; let rafId = 0, lastTs = -1, lastFrame = 0; let sessionStart = 0, lastClock = ''; const cal = { neutralYaw:0, taskYaw:0, taskPitch:0, taskGaze:0, taskGazeH:0, taskCenterY:0, taskScale:0, taskRoll:0, earOpen:0.28, noise:0.004, }; let samples = []; /* live (smoothed) values and session accumulators for each parameter */ const M = {}; METERS.forEach(m => { M[m.id] = { live: 100, sum: 0, time: 0 }; }); let overallLive = 100, overallSum = 0, overallTime = 0; let streakNow = 0, streakBest = 0; const timers = { away:0, off:0, closed:0, slouch:0, absent:0, silent:0 }; const cooldown = {}; let lastSpoke = 0; const counts = { distract:0, drowsy:0, slouch:0, away:0 }; let blinkTimes = [], eyeWasClosed = false, closedStart = 0; let speakBuf = [], prevJaw = 0, mouthMove = 0; let logCount = 0; /* =========================================================== build the UI */ function buildMeters() { const box = $('#meters'); box.innerHTML = ''; METERS.forEach(m => { const el = document.createElement('div'); el.className = 'meter'; el.id = 'meter-' + m.id; el.innerHTML = `
${m.id === 'task' ? TASK_NAME[mode] : m.name} --
${m.hint}session avg --
`; box.appendChild(el); }); refreshMeterState(); } function refreshMeterState() { const w = WEIGHTS[mode]; METERS.forEach(m => { const el = $('#meter-' + m.id); el.classList.toggle('off', w[m.id] === 0 || (m.id === 'aloud' && !$('#opt-mic').checked)); }); $('#meter-task .meter-name').textContent = TASK_NAME[mode]; } const barColor = v => v >= 78 ? 'var(--green)' : v >= 55 ? 'var(--mark)' : 'var(--red)'; /* ================================================================ controls */ $('#modes').addEventListener('click', e => { const b = e.target.closest('.mode'); if (!b || running) return; document.querySelectorAll('.mode').forEach(x => x.classList.remove('is-on')); b.classList.add('is-on'); mode = b.dataset.mode; refreshMeterState(); }); $('#opt-mic').addEventListener('change', refreshMeterState); $('#btn-start').addEventListener('click', start); $('#btn-end').addEventListener('click', () => { stopCamera(); showReport(); }); $('#btn-reset').addEventListener('click', () => { stopCamera(); hardReset(); }); $('#btn-close-sheet').addEventListener('click', () => { $('#sheet-bg').hidden = true; hardReset(); }); function setStatus(text, kind) { statusText.textContent = text; statusPill.className = 'pill pill-' + kind; } /* =================================================== start / stop / reset */ async function start() { $('#btn-start').disabled = true; try { setStatus('Loading model…', 'cal'); veilT.textContent = 'Waking up FocusPal'; veilS.textContent = 'Loading the face tracking model. This takes a few seconds the first time.'; if (!landmarker) landmarker = await loadModel(); stream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' }, audio: false, }); video.srcObject = stream; await video.play(); if ($('#opt-mic').checked && mode === 'read') await startMic(); hardReset(true); running = true; $('#btn-end').disabled = false; $('#btn-start').disabled = true; document.querySelectorAll('.mode').forEach(b => b.disabled = true); beginCalibration(); lastFrame = performance.now(); rafId = requestAnimationFrame(loop); } catch (err) { console.error(err); $('#btn-start').disabled = false; setStatus('Camera blocked', 'warn'); veilT.textContent = 'FocusPal cannot see anything'; veilS.textContent = 'Allow camera access for this page and press Start again. If this page is inside a frame, open it in its own tab first.'; } } async function loadModel() { const files = await FilesetResolver.forVisionTasks( 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@0.10.14/wasm'); const opts = { baseOptions: { modelAssetPath: 'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task', delegate: 'GPU', }, runningMode: 'VIDEO', numFaces: 1, outputFaceBlendshapes: true, }; try { return await FaceLandmarker.createFromOptions(files, opts); } catch { opts.baseOptions.delegate = 'CPU'; return await FaceLandmarker.createFromOptions(files, opts); } } async function startMic() { try { audioStream = await navigator.mediaDevices.getUserMedia({ audio: true }); audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const src = audioCtx.createMediaStreamSource(audioStream); analyser = audioCtx.createAnalyser(); analyser.fftSize = 1024; audioData = new Float32Array(analyser.fftSize); src.connect(analyser); } catch { analyser = null; } } function stopCamera() { running = false; cancelAnimationFrame(rafId); stream?.getTracks().forEach(t => t.stop()); audioStream?.getTracks().forEach(t => t.stop()); audioCtx?.close().catch(() => {}); stream = audioStream = analyser = audioCtx = null; video.srcObject = null; speechSynthesis.cancel(); ctx.clearRect(0, 0, canvas.width, canvas.height); $('#btn-end').disabled = true; $('#btn-start').disabled = false; document.querySelectorAll('.mode').forEach(b => b.disabled = false); } function hardReset(keepVeilQuiet = false) { phase = 'idle'; METERS.forEach(m => { M[m.id] = { live: 100, sum: 0, time: 0 }; }); overallLive = 100; overallSum = 0; overallTime = 0; streakNow = streakBest = 0; Object.keys(timers).forEach(k => timers[k] = 0); Object.keys(counts).forEach(k => counts[k] = 0); Object.keys(cooldown).forEach(k => delete cooldown[k]); blinkTimes = []; speakBuf = []; samples = []; eyeWasClosed = false; lastTs = -1; sessionStart = 0; lastClock = ''; logCount = 0; lastPaint = 0; paintClock(0); $('#score-num').textContent = '--'; $('#score-live').textContent = '--'; ringFill.style.strokeDashoffset = RING_LEN; $('#t-distract').textContent = '0'; $('#t-drowsy').textContent = '0'; $('#t-slouch').textContent = '0'; $('#t-streak').textContent = '0s'; METERS.forEach(m => { $('#now-' + m.id).textContent = '--'; $('#avg-' + m.id).textContent = '--'; $('#fill-' + m.id).style.width = '0%'; }); $('#log').innerHTML = '
  • Nothing yet. Start a session and this page fills itself in.
  • '; hideNudge(); if (!keepVeilQuiet) { setStatus('Camera off', 'idle'); veil.hidden = false; veilT.textContent = 'Ready when you are'; veilS.textContent = 'Sit at your desk with your book or notebook, then press Start session.'; veilC.textContent = ''; } } /* ============================================================ calibration */ let calLeft = 0; function beginCalibration() { phase = 'cal1'; samples = []; calLeft = 4; veil.hidden = false; setStatus('Setting up', 'cal'); veilT.textContent = 'Step 1 — sit the way you normally sit'; veilS.textContent = 'Look straight at the camera and stay still.'; } function beginCalibration2() { applyCal1(); phase = 'cal2'; samples = []; calLeft = 4; const what = mode === 'screen' ? 'your screen' : mode === 'write' ? 'your notebook' : 'your book'; veilT.textContent = 'Step 2 — now look at ' + what; veilS.textContent = 'Hold exactly the position you will study in. FocusPal learns this as "on task".'; } function applyCal1() { cal.neutralYaw = median(samples.map(s => s.yaw)); cal.earOpen = Math.max(median(samples.map(s => s.ear)), 0.16); cal.noise = Math.max(median(samples.map(s => s.rms)), 0.0015); } function applyCal2() { cal.taskYaw = median(samples.map(s => s.yaw)); cal.taskPitch = median(samples.map(s => s.pitch)); cal.taskGaze = median(samples.map(s => s.gazeV)); cal.taskGazeH = median(samples.map(s => s.gazeH)); cal.taskCenterY = median(samples.map(s => s.cy)); cal.taskScale = Math.max(median(samples.map(s => s.scale)), 0.05); cal.taskRoll = median(samples.map(s => s.roll)); phase = 'live'; veil.hidden = true; sessionStart = performance.now(); setStatus('Session live', 'live'); addLog('Session started. FocusPal is watching.', 'good'); speak('Session started. Stay focused.', false); } /* ================================================================== loop */ function loop() { if (!running) return; rafId = requestAnimationFrame(loop); if (video.readyState < 2) return; const now = performance.now(); const dt = clamp((now - lastFrame) / 1000, 0, 0.25); lastFrame = now; let ts = now; if (ts <= lastTs) ts = lastTs + 1; lastTs = ts; const res = landmarker.detectForVideo(video, ts); const lm = res.faceLandmarks?.[0]; const bs = readBlendshapes(res); if (canvas.width !== video.videoWidth) { canvas.width = video.videoWidth; canvas.height = video.videoHeight; } ctx.clearRect(0, 0, canvas.width, canvas.height); const f = lm ? geometry(lm, bs) : null; const rms = readMic(); if (phase === 'cal1' || phase === 'cal2') { if (f) { samples.push({ ...f, rms }); calLeft -= dt; veilC.textContent = Math.max(1, Math.ceil(calLeft)); drawFace(f, '#FFD34D'); if (calLeft <= 0 && samples.length > 10) { phase === 'cal1' ? beginCalibration2() : applyCal2(); } } else { veilC.textContent = '·'; // countdown pauses until a face is found } return; } if (phase !== 'live') return; analyse(f, bs, rms, dt, now); paintClock(now - sessionStart); } /* ---------------------------------------------- read blendshape scores */ function readBlendshapes(res) { const out = {}; const cats = res.faceBlendshapes?.[0]?.categories; if (cats) for (const c of cats) out[c.categoryName] = c.score; return out; } /* ------------------------------------------ turn landmarks into geometry */ function geometry(lm, bs) { const nose = lm[NOSE], chin = lm[CHIN], brow = lm[BROW]; const lf = lm[L_FACE], rf = lm[R_FACE]; const lo = lm[L_OUT], li = lm[L_IN], ro = lm[R_OUT], ri = lm[R_IN]; const faceW = Math.abs(rf.x - lf.x) || 1e-6; const faceH = Math.abs(chin.y - brow.y) || 1e-6; // yaw: where the nose sits between the two cheek edges (0.5 = facing camera) const yaw = (nose.x - lf.x) / faceW; // pitch: eye-line to nose distance shrinks as the head tips down const eyeY = (lo.y + li.y + ro.y + ri.y) / 4; const pitch = (nose.y - eyeY) / faceH; // roll: tilt angle of the eye line (head resting on a hand) const roll = Math.atan2(ro.y - lo.y, ro.x - lo.x) * 180 / Math.PI; // eye aspect ratio, averaged over both eyes const ear = ( Math.abs(lm[L_UP].y - lm[L_LO].y) / (Math.abs(li.x - lo.x) || 1e-6) + Math.abs(lm[R_UP].y - lm[R_LO].y) / (Math.abs(ro.x - ri.x) || 1e-6) ) / 2; const gazeV = ((bs.eyeLookDownLeft || 0) + (bs.eyeLookDownRight || 0)) / 2 - ((bs.eyeLookUpLeft || 0) + (bs.eyeLookUpRight || 0)) / 2; const gazeH = Math.max(bs.eyeLookOutLeft || 0, bs.eyeLookOutRight || 0); return { yaw, pitch, roll, ear, gazeV, gazeH, cy: (brow.y + chin.y) / 2, scale: faceH, box: { x: lf.x, y: brow.y, w: faceW, h: (chin.y - brow.y) * 1.18 }, jaw: bs.jawOpen || 0, blink: ((bs.eyeBlinkLeft || 0) + (bs.eyeBlinkRight || 0)) / 2, lm, }; } function readMic() { if (!analyser) return 0; analyser.getFloatTimeDomainData(audioData); let sum = 0; for (let i = 0; i < audioData.length; i++) sum += audioData[i] * audioData[i]; return Math.sqrt(sum / audioData.length); } /* ================================================== the scoring engine */ function analyse(f, bs, rms, dt, now) { const S = STRICT[$('#strictness').value]; const w = WEIGHTS[mode]; const micOn = analyser && mode === 'read' && $('#opt-mic').checked; /* ---- presence ------------------------------------------------------- */ const presentRaw = f ? 100 : 0; M.present.live = smooth(M.present.live, presentRaw, dt, 0.35); if (!f) { timers.absent += dt; if (timers.absent > 4) fire('absent', 'You left the desk.', 'calm', now, false); accumulate(['present'], dt); overallLive = 0.55 * M.present.live; // absence still costs the session overallStep(overallLive, dt); streakNow = 0; paintAll(now); return; } timers.absent = 0; /* ---- on-task gaze ---------------------------------------------------- */ const dYaw = Math.abs(f.yaw - cal.taskYaw); const dPitch = Math.abs(f.pitch - cal.taskPitch); const dGazeV = Math.abs(f.gazeV - cal.taskGaze); const dGazeH = Math.abs(f.gazeH - cal.taskGazeH); const taskOff = Math.max( over(dYaw, 0.055 * S, 0.145 * S) * 0.9, over(dPitch, 0.030 * S, 0.090 * S), over(dGazeV, 0.190 * S, 0.520 * S) * 0.8, ); M.task.live = smooth(M.task.live, 100 * (1 - taskOff), dt, 0.5); /* ---- looking away sideways ------------------------------------------- */ const steadyOff = Math.max( over(dYaw, 0.055 * S, 0.145 * S), over(dGazeH, 0.260 * S, 0.680 * S) * 0.7, ); M.steady.live = smooth(M.steady.live, 100 * (1 - steadyOff), dt, 0.5); /* ---- alertness -------------------------------------------------------- */ const earNorm = f.ear / cal.earOpen; const closed = earNorm < 0.62 || f.blink > 0.55; if (closed) { if (!eyeWasClosed) { eyeWasClosed = true; closedStart = now; } timers.closed += dt; } else { if (eyeWasClosed && now - closedStart < 500) blinkTimes.push(now); eyeWasClosed = false; timers.closed = 0; } blinkTimes = blinkTimes.filter(t => now - t < 60000); const blinkRate = blinkTimes.length; const alertRaw = 100 - 100 * over(timers.closed, 0.35 * S, 1.6 * S) - 30 * over(blinkRate, 30 * S, 55 * S); M.alert.live = smooth(M.alert.live, clamp(alertRaw, 0, 100), dt, 0.6); /* ---- posture ---------------------------------------------------------- */ const drop = f.cy - cal.taskCenterY; // face sinking in frame const lean = f.scale / cal.taskScale - 1; // leaning too close const tilt = Math.abs(f.roll - cal.taskRoll); // head on a hand const postureOff = Math.max( over(drop, 0.035 * S, 0.120 * S), over(lean, 0.170 * S, 0.480 * S), over(tilt, 7.0 * S, 22.0 * S), ); M.posture.live = smooth(M.posture.live, 100 * (1 - postureOff), dt, 0.8); /* ---- reading aloud ---------------------------------------------------- */ mouthMove = smooth(mouthMove, Math.abs(f.jaw - prevJaw) * 30, dt, 0.4); prevJaw = f.jaw; if (micOn) { const voiced = rms > Math.max(cal.noise * 2.2, cal.noise + 0.004); const speaking = voiced && (f.jaw > 0.045 || mouthMove > 0.12); speakBuf.push({ t: now, on: speaking }); speakBuf = speakBuf.filter(s => now - s.t < 20000); const frac = speakBuf.filter(s => s.on).length / Math.max(speakBuf.length, 1); const warmUp = now - sessionStart < 10000; M.aloud.live = warmUp ? 100 : smooth(M.aloud.live, clamp(frac / 0.40, 0, 1) * 100, dt, 1.2); timers.silent = speaking ? 0 : timers.silent + dt; } else { M.aloud.live = 100; } /* ---- combine into one live score ------------------------------------- */ let total = 0, wsum = 0; for (const m of METERS) { const weight = (m.id === 'aloud' && !micOn) ? 0 : w[m.id]; if (!weight) continue; total += M[m.id].live * weight; wsum += weight; } overallLive = wsum ? total / wsum : 100; const active = METERS.map(m => m.id) .filter(id => (id === 'aloud' ? micOn : w[id] > 0)); accumulate(active, dt); overallStep(overallLive, dt); /* ---- focus streak ----------------------------------------------------- */ if (overallLive >= 75) { streakNow += dt; streakBest = Math.max(streakBest, streakNow); } else streakNow = 0; /* ---- events + the voice coach ---------------------------------------- */ timers.away = steadyOff > 0.60 ? timers.away + dt : 0; timers.off = taskOff > 0.55 ? timers.off + dt : 0; timers.slouch = postureOff > 0.60 ? timers.slouch + dt : 0; if (timers.away > 2.2 * S) fire('away', pick(FIRM), 'firm', now, true, 'Looked away from your work'); else if (timers.off > 3.6 * S) fire('off', pick(FIRM), 'firm', now, true, 'Not looking at your work'); if (timers.closed > 1.3 * S) fire('drowsy', pick(CALM), 'calm', now, true, 'Eyes closing — drowsy'); if (timers.slouch > 6 * S) fire('slouch', pick(CALM), 'calm', now, true, 'Slouching over the desk'); if (micOn && timers.silent > 14 * S) fire('silent', pick(ALOUD_LINES), 'calm', now, true, 'Reading silently, not aloud'); drawFace(f, overallLive >= 75 ? '#37D6A0' : overallLive >= 55 ? '#FFD34D' : '#FF5468'); paintAll(now); } function accumulate(ids, dt) { for (const id of ids) { M[id].sum += M[id].live * dt; M[id].time += dt; } } function overallStep(v, dt) { overallSum += v * dt; overallTime += dt; } const avg = (id) => M[id].time > 0 ? M[id].sum / M[id].time : null; const pick = (a) => a[Math.floor(Math.random() * a.length)]; /* ------------------------------------------------------- event dispatch */ const TIMER_OF = { away:'away', off:'off', slouch:'slouch', silent:'silent', drowsy:'closed', absent:'absent' }; function fire(key, line, tone, now, countIt = true, logLine = null) { if (cooldown[key] && now - cooldown[key] < 12000) return; cooldown[key] = now; timers[TIMER_OF[key]] = 0; if (countIt) { if (key === 'away' || key === 'off') counts.distract++; if (key === 'drowsy') counts.drowsy++; if (key === 'slouch') counts.slouch++; } if (key === 'absent') counts.away++; addLog(logLine || line, tone === 'firm' ? 'bad' : 'calm'); showNudge(line, tone); speak(line, tone === 'firm'); if ($('#opt-beep').checked) beep(tone === 'firm'); } /* ------------------------------------------------------------- feedback */ let nudgeTimer = 0; function showNudge(text, tone) { nudgeT.textContent = text; nudge.className = 'nudge show' + (tone === 'firm' ? '' : ' calm'); clearTimeout(nudgeTimer); nudgeTimer = setTimeout(hideNudge, 4000); } function hideNudge() { nudge.className = 'nudge'; } function speak(text, firm) { if (!$('#opt-voice').checked || !('speechSynthesis' in window)) return; const now = performance.now(); if (now - lastSpoke < 6000) return; lastSpoke = now; speechSynthesis.cancel(); const u = new SpeechSynthesisUtterance(text); u.rate = firm ? 1.08 : 0.92; u.pitch = firm ? 0.85 : 1.12; u.volume = 1; speechSynthesis.speak(u); } function beep(firm) { try { const a = new (window.AudioContext || window.webkitAudioContext)(); const o = a.createOscillator(), g = a.createGain(); o.connect(g); g.connect(a.destination); o.type = firm ? 'square' : 'sine'; o.frequency.value = firm ? 720 : 480; g.gain.setValueAtTime(0.18, a.currentTime); g.gain.exponentialRampToValueAtTime(0.001, a.currentTime + 0.35); o.start(); o.stop(a.currentTime + 0.36); setTimeout(() => a.close().catch(() => {}), 600); } catch {} } function addLog(text, kind) { const list = $('#log'); list.querySelector('.log-empty')?.remove(); const li = document.createElement('li'); li.className = kind === 'bad' ? '' : kind === 'good' ? 'good' : 'calm'; const el = sessionStart ? performance.now() - sessionStart : 0; li.innerHTML = ``; li.querySelector('span').textContent = text; list.prepend(li); if (++logCount > 60) list.lastElementChild?.remove(); } /* ----------------------------------------------------------- the canvas */ function drawFace(f, colour) { const W = canvas.width, H = canvas.height; const x = f.box.x * W, y = f.box.y * H, w = f.box.w * W, h = f.box.h * H; ctx.strokeStyle = colour; ctx.lineWidth = 3; const r = 14; ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); ctx.stroke(); if ($('#opt-mesh').checked) { ctx.fillStyle = colour; for (let i = 0; i < f.lm.length; i += 4) ctx.fillRect(f.lm[i].x * W, f.lm[i].y * H, 1.6, 1.6); } } /* ------------------------------------------------------------- painting */ let lastPaint = 0; function paintAll(now = performance.now()) { if (now - lastPaint < 100) return; // repaint at ~10 fps, not every frame lastPaint = now; for (const m of METERS) { const v = Math.round(M[m.id].live); $('#now-' + m.id).textContent = v; const fill = $('#fill-' + m.id); fill.style.width = v + '%'; fill.style.background = barColor(v); const a = avg(m.id); $('#avg-' + m.id).textContent = a === null ? '--' : Math.round(a); } const sess = overallTime > 0 ? overallSum / overallTime : 100; $('#score-num').textContent = Math.round(sess); $('#score-live').textContent = Math.round(overallLive); ringFill.style.strokeDashoffset = RING_LEN * (1 - clamp(sess, 0, 100) / 100); ringFill.style.stroke = barColor(sess); $('#t-distract').textContent = counts.distract; $('#t-drowsy').textContent = counts.drowsy; $('#t-slouch').textContent = counts.slouch; $('#t-streak').textContent = fmtStreak(streakBest); setStatus(overallLive < 55 ? 'Distracted' : 'Session live', overallLive < 55 ? 'warn' : 'live'); } function fmtStreak(s) { s = Math.floor(s); return s < 60 ? s + 's' : Math.floor(s / 60) + 'm ' + (s % 60) + 's'; } function mmss(ms) { const t = Math.floor(ms / 1000); return String(Math.floor(t / 60)).padStart(2, '0') + ':' + String(t % 60).padStart(2, '0'); } /* ---------------------------------------------------------- block clock */ function paintClock(ms) { const t = Math.floor(ms / 1000); const str = [Math.floor(t / 3600), Math.floor(t / 60) % 60, t % 60] .map(n => String(n).padStart(2, '0')).join(''); if (str === lastClock) return; lastClock = str; digits().forEach((d, i) => { if (d.textContent !== str[i]) d.textContent = str[i]; }); } /* ============================================================== report */ function showReport() { const sess = overallTime > 0 ? overallSum / overallTime : 0; const score = Math.round(sess); const mins = overallTime / 60; const grade = score >= 85 ? 'Excellent focus' : score >= 70 ? 'Good session' : score >= 55 ? 'Fair — you can do better' : 'Needs work'; $('#rp-title').textContent = grade; $('#rp-line').textContent = `You studied for ${mins < 1 ? 'less than a minute' : Math.round(mins) + ' minutes'} ` + `and scored ${score} out of 100. Your longest unbroken stretch of focus was ` + `${fmtStreak(streakBest)}.`; const w = WEIGHTS[mode]; const rows = METERS .filter(m => w[m.id] > 0 && M[m.id].time > 0) .map(m => ({ m, v: Math.round(avg(m.id)) })); $('#rp-stats').innerHTML = rows.map(r => `
    ${r.m.id === 'task' ? TASK_NAME[mode] : r.m.name}${r.v}
    `).join('') + `
    Times distracted${counts.distract}
    ` + `
    Drowsy moments${counts.drowsy}
    `; const tips = []; const weakest = [...rows].sort((a, b) => a.v - b.v)[0]; const TIP = { task: 'Move your book directly in front of you so your eyes do not have to travel far.', steady: 'Put your phone in another room. Most of your slips were sideways glances.', aloud: 'Read the difficult paragraphs out loud — saying it helps you remember it.', alert: 'Try 25 minutes of study then a 5 minute break. Sleep matters more than extra hours.', posture:'Raise your book on a stand and keep both feet flat on the floor.', present:'Keep water and stationery on the desk before you start, so you do not get up.', }; if (weakest && weakest.v < 85) tips.push(TIP[weakest.m.id]); if (counts.distract >= 4) tips.push('You broke focus ' + counts.distract + ' times. Aim for half that next session.'); if (counts.drowsy >= 2) tips.push('Drowsiness showed up more than once. Study earlier in the day if you can.'); if (streakBest < 300 && mins > 6) tips.push('Try to hold one unbroken 5 minute stretch before you check anything.'); if (!tips.length) tips.push('Strong session. Keep the same desk setup tomorrow and add five minutes.'); $('#rp-tips').innerHTML = tips.map(t => `
  • `).join(''); $('#rp-tips').querySelectorAll('li').forEach((li, i) => li.textContent = tips[i]); $('#sheet-bg').hidden = false; } /* ================================================================= boot */ buildMeters(); paintClock(0); ringFill.style.strokeDasharray = RING_LEN; ringFill.style.strokeDashoffset = RING_LEN;