Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useRef, useCallback } from 'react'; | |
| const COLLECT_MS = 2000; | |
| const CENTER_MS = 3000; // centre point gets extra time (bias reference) | |
| function CalibrationOverlay({ calibration, videoManager }) { | |
| const [progress, setProgress] = useState(0); | |
| const timerRef = useRef(null); | |
| const startRef = useRef(null); | |
| const overlayRef = useRef(null); | |
| const enterFullscreen = useCallback(() => { | |
| const el = overlayRef.current; | |
| if (!el) return; | |
| const req = el.requestFullscreen || el.webkitRequestFullscreen || el.msRequestFullscreen; | |
| if (req) req.call(el).catch(() => {}); | |
| }, []); | |
| const exitFullscreen = useCallback(() => { | |
| if (document.fullscreenElement || document.webkitFullscreenElement) { | |
| const exit = document.exitFullscreen || document.webkitExitFullscreen || document.msExitFullscreen; | |
| if (exit) exit.call(document).catch(() => {}); | |
| } | |
| }, []); | |
| useEffect(() => { | |
| if (calibration && calibration.active && !calibration.done) { | |
| const t = setTimeout(enterFullscreen, 100); | |
| return () => clearTimeout(t); | |
| } | |
| }, [calibration?.active]); | |
| useEffect(() => { | |
| if (!calibration || !calibration.active) exitFullscreen(); | |
| }, [calibration?.active]); | |
| useEffect(() => { | |
| if (!calibration || !calibration.collecting || calibration.done) { | |
| setProgress(0); | |
| if (timerRef.current) cancelAnimationFrame(timerRef.current); | |
| return; | |
| } | |
| startRef.current = performance.now(); | |
| const duration = calibration.index === 0 ? CENTER_MS : COLLECT_MS; | |
| const tick = () => { | |
| const pct = Math.min((performance.now() - startRef.current) / duration, 1); | |
| setProgress(pct); | |
| if (pct >= 1) { | |
| if (videoManager) videoManager.nextCalibrationPoint(); | |
| startRef.current = performance.now(); | |
| setProgress(0); | |
| } | |
| timerRef.current = requestAnimationFrame(tick); | |
| }; | |
| timerRef.current = requestAnimationFrame(tick); | |
| return () => { if (timerRef.current) cancelAnimationFrame(timerRef.current); }; | |
| }, [calibration?.index, calibration?.collecting, calibration?.done]); | |
| const handleCancel = () => { | |
| if (videoManager) videoManager.cancelCalibration(); | |
| exitFullscreen(); | |
| }; | |
| if (!calibration || !calibration.active) return null; | |
| if (calibration.done) { | |
| return ( | |
| <div ref={overlayRef} style={overlayStyle}> | |
| <div style={messageBoxStyle}> | |
| <h2 style={{ margin: '0 0 10px', color: calibration.success ? '#4ade80' : '#f87171' }}> | |
| {calibration.success ? 'Calibration Complete' : 'Calibration Failed'} | |
| </h2> | |
| <p style={{ color: '#ccc', margin: 0 }}> | |
| {calibration.success | |
| ? 'Gaze tracking is now active.' | |
| : 'Not enough samples collected. Try again.'} | |
| </p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| const [tx, ty] = calibration.target || [0.5, 0.5]; | |
| return ( | |
| <div ref={overlayRef} style={overlayStyle}> | |
| <div style={{ | |
| position: 'absolute', top: '30px', left: '50%', transform: 'translateX(-50%)', | |
| color: '#fff', fontSize: '16px', textAlign: 'center', | |
| textShadow: '0 0 8px rgba(0,0,0,0.8)', pointerEvents: 'none', | |
| }}> | |
| <div style={{ fontWeight: 'bold', fontSize: '20px' }}> | |
| Look at the dot ({calibration.index + 1}/{calibration.numPoints}) | |
| </div> | |
| <div style={{ fontSize: '14px', color: '#aaa', marginTop: '6px' }}> | |
| {calibration.index === 0 | |
| ? 'Look at the center dot - this sets your baseline' | |
| : 'Hold your gaze steady on the target'} | |
| </div> | |
| </div> | |
| <div style={{ | |
| position: 'absolute', left: `${tx * 100}%`, top: `${ty * 100}%`, | |
| transform: 'translate(-50%, -50%)', | |
| }}> | |
| <svg width="60" height="60" style={{ position: 'absolute', left: '-30px', top: '-30px' }}> | |
| <circle cx="30" cy="30" r="24" fill="none" stroke="rgba(255,255,255,0.15)" strokeWidth="3" /> | |
| <circle cx="30" cy="30" r="24" fill="none" stroke="#4ade80" strokeWidth="3" | |
| strokeDasharray={`${progress * 150.8} 150.8`} strokeLinecap="round" | |
| transform="rotate(-90, 30, 30)" /> | |
| </svg> | |
| <div style={{ | |
| width: '20px', height: '20px', borderRadius: '50%', | |
| background: 'radial-gradient(circle, #fff 30%, #4ade80 100%)', | |
| boxShadow: '0 0 20px rgba(74, 222, 128, 0.8)', | |
| }} /> | |
| </div> | |
| <button onClick={handleCancel} style={{ | |
| position: 'absolute', bottom: '40px', left: '50%', transform: 'translateX(-50%)', | |
| padding: '10px 28px', background: 'rgba(255,255,255,0.1)', | |
| border: '1px solid rgba(255,255,255,0.3)', color: '#fff', | |
| borderRadius: '20px', cursor: 'pointer', fontSize: '14px', | |
| }}> | |
| Cancel Calibration | |
| </button> | |
| </div> | |
| ); | |
| } | |
| const overlayStyle = { | |
| position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', | |
| background: 'rgba(0, 0, 0, 0.92)', zIndex: 10000, | |
| display: 'flex', alignItems: 'center', justifyContent: 'center', | |
| }; | |
| const messageBoxStyle = { | |
| textAlign: 'center', padding: '30px 40px', | |
| background: 'rgba(30, 30, 50, 0.9)', borderRadius: '16px', | |
| border: '1px solid rgba(255,255,255,0.1)', | |
| }; | |
| export default CalibrationOverlay; | |