├── .gitignore ├── .idea ├── .gitignore ├── eyesite.iml ├── material_theme_project_new.xml ├── modules.xml └── vcs.xml ├── README.md ├── eslint.config.mjs ├── jsconfig.json ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── calibration.jpg ├── gazedebug.jpg ├── gazepage.jpg ├── inandout.gif ├── jitter.gif └── thumbnail.jpg ├── src ├── app │ ├── favicon.ico │ ├── globals.css │ ├── layout.js │ └── page.js └── components │ ├── Blog.js │ ├── ClickableSquare.js │ ├── GazeClickWrapper.js │ ├── GazeWrapper.js │ ├── LandingScreen.js │ ├── WrappedClickableSquare.js │ ├── WrappedSquare.js │ ├── calibrate.js │ ├── gaze.js │ ├── screenTooSmallWindow.js │ ├── seeableTexts │ ├── blogButton.js │ ├── lookAndClick.js │ └── playAGame.js │ ├── square.js │ ├── useGazeClick.js │ ├── useGazeHover.js │ └── webgazerProvider.js └── tailwind.config.mjs /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | -------------------------------------------------------------------------------- /.idea/eyesite.iml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <module type="WEB_MODULE" version="4"> 3 | <component name="NewModuleRootManager"> 4 | <content url="file://$MODULE_DIRquot;> 5 | <excludeFolder url="file://$MODULE_DIR$/.tmp" /> 6 | <excludeFolder url="file://$MODULE_DIR$/temp" /> 7 | <excludeFolder url="file://$MODULE_DIR$/tmp" /> 8 | </content> 9 | <orderEntry type="inheritedJdk" /> 10 | <orderEntry type="sourceFolder" forTests="false" /> 11 | </component> 12 | </module> -------------------------------------------------------------------------------- /.idea/material_theme_project_new.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <project version="4"> 3 | <component name="MaterialThemeProjectNewConfig"> 4 | <option name="metadata"> 5 | <MTProjectMetadataState> 6 | <option name="migrated" value="true" /> 7 | <option name="pristineConfig" value="false" /> 8 | <option name="userId" value="-3d87ee0d:184b24f8319:-8000" /> 9 | <option name="version" value="6.16.2" /> 10 | </MTProjectMetadataState> 11 | </option> 12 | </component> 13 | </project> -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <project version="4"> 3 | <component name="ProjectModuleManager"> 4 | <modules> 5 | <module fileurl="file://$PROJECT_DIR$/.idea/eyesite.iml" filepath="$PROJECT_DIR$/.idea/eyesite.iml" /> 6 | </modules> 7 | </component> 8 | </project> -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <project version="4"> 3 | <component name="VcsDirectoryMappings"> 4 | <mapping directory="" vcs="Git" /> 5 | </component> 6 | </project> -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Eyesite 2 | 3 | An experimental website combining computer vision and web design. 4 | 5 | This project primarily uses [webgazer](https://webgazer.cs.brown.edu/) for eye tracking. 6 | 7 | - Control your cursor with your eyes 8 | - Press space to click 9 | - Press D to debug 10 | - Press R to recalibrate 11 | 12 | ## Running the project localy 13 | 1. Clone the project 14 | 2. Install dependencies with `npm install` 15 | 3. Run next dev with `npm run dev` -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [...compat.extends("next/core-web-vitals")]; 13 | 14 | export default eslintConfig; 15 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { 4 | "@/*": ["./src/*"] 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eyesite", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@headlessui/react": "^2.2.4", 13 | "next": "15.1.8", 14 | "react": "^19.0.0", 15 | "react-dom": "^19.0.0", 16 | "webgazer": "^3.3.0" 17 | }, 18 | "devDependencies": { 19 | "@eslint/eslintrc": "^3", 20 | "eslint": "^9", 21 | "eslint-config-next": "15.1.8", 22 | "postcss": "^8", 23 | "tailwindcss": "^3.4.1" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/calibration.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/public/calibration.jpg -------------------------------------------------------------------------------- /public/gazedebug.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/public/gazedebug.jpg -------------------------------------------------------------------------------- /public/gazepage.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/public/gazepage.jpg -------------------------------------------------------------------------------- /public/inandout.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/public/inandout.gif -------------------------------------------------------------------------------- /public/jitter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/public/jitter.gif -------------------------------------------------------------------------------- /public/thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/public/thumbnail.jpg -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akchro/eyesite/3ee7ba304350580a26c517b94d8d68333d6fec7c/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #ffffff; 7 | --foreground: #171717; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #0a0a0a; 13 | --foreground: #ededed; 14 | } 15 | } 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | font-family: Arial, Helvetica, sans-serif; 21 | } 22 | 23 | /*!* WebGazer styles *!*/ 24 | #webgazerVideoContainer { 25 | display: block !important; 26 | position: fixed !important; 27 | top: 20px !important; 28 | left: 0px !important; 29 | width: 320px !important; 30 | height: 240px !important; 31 | z-index: 100; 32 | } 33 | 34 | 35 | 36 | /* Calibration point pulse animation */ 37 | @keyframes pulse { 38 | 0% { 39 | transform: scale(0.95); 40 | box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7); 41 | } 42 | 43 | 70% { 44 | transform: scale(1); 45 | box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); 46 | } 47 | 48 | 100% { 49 | transform: scale(0.95); 50 | box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); 51 | } 52 | } 53 | 54 | .calibration-point:not(.calibrated) { 55 | animation: pulse 2s infinite; 56 | } 57 | 58 | /* Gaze interaction styles */ 59 | .gaze-target { 60 | transition: all 0.3s ease; 61 | cursor: pointer; 62 | } 63 | 64 | .gaze-target:hover, 65 | .gaze-target[data-gaze-hovered="true"] { 66 | transform: scale(1.05); 67 | box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); 68 | } 69 | 70 | /* Gaze indicator dot */ 71 | .gaze-dot { 72 | position: fixed; 73 | width: 10px; 74 | height: 10px; 75 | background: rgba(255, 0, 0, 0.7); 76 | border-radius: 50%; 77 | pointer-events: none; 78 | z-index: 9999; 79 | transform: translate(-50%, -50%); 80 | } 81 | 82 | /* Gaze click flash effects */ 83 | @keyframes gazeClickFlash { 84 | 0% { 85 | background-color: rgb(250, 204, 21); /* yellow-400 */ 86 | transform: scale(1.1); 87 | box-shadow: 0 0 20px rgba(250, 204, 21, 0.6); 88 | } 89 | 50% { 90 | background-color: rgb(251, 191, 36); /* yellow-300 */ 91 | transform: scale(1.15); 92 | box-shadow: 0 0 30px rgba(251, 191, 36, 0.8); 93 | } 94 | 100% { 95 | transform: scale(1); 96 | box-shadow: none; 97 | } 98 | } 99 | 100 | .gaze-click-flash { 101 | animation: gazeClickFlash 0.3s ease-out; 102 | } 103 | 104 | /* Gaze hover state */ 105 | [data-gaze-hovered="true"] { 106 | border-color: rgb(239, 68, 68) !important; /* red-500 */ 107 | box-shadow: 0 0 10px rgba(239, 68, 68, 0.3); 108 | } 109 | 110 | /* Spacebar instruction */ 111 | .spacebar-instruction { 112 | background: linear-gradient(45deg, #3b82f6, #1d4ed8); 113 | color: white; 114 | padding: 4px 8px; 115 | border-radius: 4px; 116 | font-size: 12px; 117 | animation: pulse 2s infinite; 118 | } 119 | 120 | /* Debug mode styles */ 121 | .debug-mode-indicator { 122 | background: linear-gradient(45deg, #d97706, #f59e0b); 123 | color: white; 124 | padding: 4px 8px; 125 | border-radius: 4px; 126 | font-weight: bold; 127 | font-size: 12px; 128 | animation: debugPulse 1.5s infinite; 129 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 130 | } 131 | 132 | @keyframes debugPulse { 133 | 0%, 100% { 134 | opacity: 1; 135 | transform: scale(1); 136 | } 137 | 50% { 138 | opacity: 0.8; 139 | transform: scale(1.05); 140 | } 141 | } 142 | 143 | /* Debug mode overlay when camera is visible */ 144 | .debug-camera-overlay { 145 | position: fixed; 146 | top: 0; 147 | left: 200px; 148 | background: rgba(217, 119, 6, 0.9); 149 | color: white; 150 | padding: 2px 6px; 151 | font-size: 10px; 152 | font-weight: bold; 153 | z-index: 1004; 154 | border-radius: 0 0 4px 0; 155 | } 156 | -------------------------------------------------------------------------------- /src/app/layout.js: -------------------------------------------------------------------------------- 1 | import { Geist, Geist_Mono, Red_Hat_Text, Cormorant_Garamond } from "next/font/google"; 2 | import {WebgazerProvider} from "@/components/webgazerProvider"; 3 | import LandingScreen from "@/components/LandingScreen"; 4 | import "./globals.css"; 5 | 6 | const geistSans = Geist({ 7 | variable: "--font-geist-sans", 8 | subsets: ["latin"], 9 | }); 10 | 11 | const geistMono = Geist_Mono({ 12 | variable: "--font-geist-mono", 13 | subsets: ["latin"], 14 | }); 15 | 16 | const redHatSans = Red_Hat_Text({ 17 | variable: "--font-red-hat", 18 | subsets: ["latin"] 19 | }) 20 | 21 | const cormorantGaramond = Cormorant_Garamond({ 22 | variable: "--font-cormorant-garamond", 23 | weight: ["300", "400", "500", "600", "700"], 24 | subsets: ["latin"], 25 | }); 26 | 27 | export const metadata = { 28 | title: "Eyesite", 29 | description: "Eye tracking cursor experimental", 30 | }; 31 | 32 | export default function RootLayout({ children }) { 33 | return ( 34 | <html lang="en"> 35 | <body 36 | className={`${redHatSans.variable} ${geistMono.variable} ${cormorantGaramond.variable} max-h-screen max-w-screen overflow-hidden antialiased`} 37 | > 38 | <WebgazerProvider> 39 | <LandingScreen> 40 | {children} 41 | </LandingScreen> 42 | </WebgazerProvider> 43 | </body> 44 | </html> 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/app/page.js: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Gaze from "@/components/gaze"; 3 | 4 | 5 | export const metadata = { 6 | title: "eyesite", 7 | description: "Check my stuff out.", 8 | openGraph: { 9 | type: "website", 10 | url: "https://eyesite.andykhau.com/", 11 | title: "eyesite", 12 | description: "An experimental website combining computer vision and web design.", 13 | images: [ 14 | { 15 | url: "/thumbnail.jpg", 16 | width: 1200, 17 | height: 630, 18 | alt: "eyesite", 19 | }, 20 | ], 21 | }, 22 | twitter: { 23 | card: "summary_large_image", 24 | title: "eyesite", 25 | description: "An experimental website combining computer vision and web design.", 26 | images: ["/thumbnail.jpg"], 27 | }, 28 | }; 29 | 30 | export default function Home() { 31 | return ( 32 | <Gaze /> 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/components/Blog.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRef, useState, useEffect, useCallback } from 'react'; 4 | import { useWebGazer } from './webgazerProvider'; 5 | 6 | const Blog = ({ debugMode, onExit }) => { 7 | const blogContentRef = useRef(null); 8 | const { currentGaze, isReady, addGazeListener } = useWebGazer(); 9 | const [scrollSpeed, setScrollSpeed] = useState(0); 10 | const [scrollProgress, setScrollProgress] = useState(0); 11 | const scrollIntervalRef = useRef(null); 12 | 13 | // Handle gaze-based scrolling 14 | const checkGazePosition = useCallback((data) => { 15 | if (!data || !blogContentRef.current) { 16 | setScrollSpeed(0); 17 | return; 18 | } 19 | 20 | const { y } = data; 21 | const windowHeight = window.innerHeight; 22 | const bottomThreshold = windowHeight * 0.75; // Bottom 25% of screen 23 | const topThreshold = windowHeight * 0.15; // Top 15% of screen 24 | 25 | if (y >= bottomThreshold) { 26 | // Looking at bottom - scroll down 27 | setScrollSpeed(10); 28 | } else if (y <= topThreshold) { 29 | // Looking at top - scroll up 30 | setScrollSpeed(-10); 31 | } else { 32 | // Looking at middle - stop scrolling 33 | setScrollSpeed(0); 34 | } 35 | }, []); 36 | 37 | // Handle scroll progress tracking 38 | const handleScroll = useCallback(() => { 39 | if (!blogContentRef.current) return; 40 | 41 | const { scrollTop, scrollHeight, clientHeight } = blogContentRef.current; 42 | const maxScroll = scrollHeight - clientHeight; 43 | const progress = maxScroll > 0 ? (scrollTop / maxScroll) * 100 : 0; 44 | setScrollProgress(Math.min(100, Math.max(0, progress))); 45 | }, []); 46 | 47 | // Handle spacebar press for exit 48 | const handleKeyPress = useCallback((event) => { 49 | if (event.code === 'Space') { 50 | event.preventDefault(); 51 | onExit(); 52 | } 53 | }, [onExit]); 54 | 55 | // Set up gaze listener 56 | useEffect(() => { 57 | if (!isReady || !addGazeListener) return; 58 | 59 | const removeListener = addGazeListener(checkGazePosition); 60 | return removeListener; 61 | }, [isReady, addGazeListener, checkGazePosition]); 62 | 63 | // Set up keyboard listener 64 | useEffect(() => { 65 | window.addEventListener('keydown', handleKeyPress); 66 | return () => { 67 | window.removeEventListener('keydown', handleKeyPress); 68 | }; 69 | }, [handleKeyPress]); 70 | 71 | // Set up scroll listener 72 | useEffect(() => { 73 | const blogElement = blogContentRef.current; 74 | if (!blogElement) return; 75 | 76 | blogElement.addEventListener('scroll', handleScroll); 77 | // Initial calculation 78 | handleScroll(); 79 | 80 | return () => { 81 | blogElement.removeEventListener('scroll', handleScroll); 82 | }; 83 | }, [handleScroll]); 84 | 85 | // Handle continuous scrolling 86 | useEffect(() => { 87 | if (scrollSpeed !== 0) { 88 | scrollIntervalRef.current = setInterval(() => { 89 | if (blogContentRef.current) { 90 | blogContentRef.current.scrollTop += scrollSpeed; 91 | } 92 | }, 16); // ~60fps 93 | } else { 94 | if (scrollIntervalRef.current) { 95 | clearInterval(scrollIntervalRef.current); 96 | scrollIntervalRef.current = null; 97 | } 98 | } 99 | 100 | return () => { 101 | if (scrollIntervalRef.current) { 102 | clearInterval(scrollIntervalRef.current); 103 | } 104 | }; 105 | }, [scrollSpeed]); 106 | 107 | return ( 108 | <div className="fixed inset-0 bg-gray-950 z-50 flex flex-col"> 109 | {/* Custom Scroll Progress Bar */} 110 | <div className="fixed right-6 top-1/2 transform -translate-y-1/2 z-10"> 111 | <div className="relative"> 112 | {/* Background track */} 113 | <div className="w-1 h-80 bg-gray-700/50 rounded-full"></div> 114 | {/* Progress fill */} 115 | <div 116 | className="absolute top-0 left-0 w-1 bg-gradient-to-b from-blue-400 to-blue-600 rounded-full transition-all duration-200 ease-out" 117 | style={{ height: `${(scrollProgress / 100) * 320}px` }} 118 | ></div> 119 | {/* Progress indicator dot */} 120 | <div 121 | className="absolute left-1/2 transform -translate-x-1/2 w-3 h-3 bg-blue-400 rounded-full border-2 border-gray-950 transition-all duration-200 ease-out" 122 | style={{ 123 | top: `${(scrollProgress / 100) * 308}px`, // 320px - 12px for dot size 124 | opacity: scrollProgress > 0 ? 1 : 0.5 125 | }} 126 | ></div> 127 | </div> 128 | </div> 129 | 130 | {/* Blog Content */} 131 | <hr className={`border absolute top-[15%] w-screen ${debugMode ? '' : 'hidden'}`} /> 132 | <hr className={`border absolute bottom-1/4 w-screen ${debugMode ? '' : 'hidden'}`} /> 133 | <div 134 | ref={blogContentRef} 135 | className="flex-1 overflow-y-auto px-8 py-12 max-w-4xl mx-auto" 136 | style={{ scrollBehavior: 'auto' }} 137 | > 138 | <div className="text-white space-y-8"> 139 | <h1 className="text-6xl font-cor-gar text-center mb-12">Blog</h1> 140 | <blockquote className="border-l-4 border-blue-400 pl-6 py-4 mb-12 bg-gray-800/30 rounded-r-lg"> 141 | <p className="text-lg font-red-hat leading-relaxed text-gray-300 italic"> 142 | This is just a showcase of gaze reading. To read the blog in a more convenient way, check out{' '} 143 | <a 144 | href="https://blog.andykhau.com/blog/eyesite" 145 | className="text-blue-400 hover:text-blue-300 underline not-italic font-medium" 146 | target="_blank" 147 | rel="noopener noreferrer" 148 | > 149 | blog.andykhau.com/blog/eyesite 150 | </a> 151 | </p> 152 | </blockquote> 153 | {/* Real Blog Content */} 154 | <article className="space-y-6"> 155 | <p className="text-lg font-red-hat leading-relaxed"> 156 | I wanted Apple Vision Pros, but I don't have $3,500 in my back pocket. So I made Apple Vision Pros at home. 157 | </p> 158 | 159 | <p className="text-lg font-red-hat leading-relaxed"> 160 | I was interested in making a project that combined computer vision with web design—a website that users could <em>physically</em> interact with. This inspired me to make{' '} 161 | <a href="https://eyesite.andykhau.com/" className="text-blue-400 hover:text-blue-300 underline"> 162 | Eyesite 163 | </a>, because who needs a mouse when you have your eyes? 164 | </p> 165 | 166 | <h2 className="text-4xl font-cor-gar mt-12 mb-6">Eye tracking</h2> 167 | 168 | <p className="text-lg font-red-hat leading-relaxed"> 169 | Luckily, there is already a Javascript library for eye tracking called{' '} 170 | <a href="https://webgazer.cs.brown.edu/" className="text-blue-400 hover:text-blue-300 underline"> 171 | WebGazer.js 172 | </a>. We can achieve decent eye tracking through calibration: 173 | </p> 174 | 175 | <ol className="text-lg font-red-hat leading-relaxed ml-6 space-y-2"> 176 | <li>1. Make the user look at a point and click. This maps the current gaze to a point on the screen.</li> 177 | <li>2. Feed the gaze/coordinate mapping into WebGazer to calibrate.</li> 178 | <li>3. Repeat 9x times on the corners, sides, and center to get good mapping data.</li> 179 | </ol> 180 | 181 | <p className="text-lg font-red-hat leading-relaxed"> 182 | I found that it was best to get 5 mappings per point for better eye tracking accuracy. 183 | </p> 184 | 185 | <div className="my-8"> 186 | <img 187 | src="/calibration.jpg" 188 | alt="Calibration screen in debug mode" 189 | className="w-full max-w-2xl mx-auto rounded-lg shadow-lg" 190 | /> 191 | <p className="text-sm font-red-hat text-gray-400 text-center mt-2 italic"> 192 | Calibration in debug mode. The top right shows how WebGazer tracks your eyes and face. The red dot is where it thinks I'm looking. 193 | </p> 194 | </div> 195 | 196 | <h2 className="text-4xl font-cor-gar mt-12 mb-6">Website Interaction</h2> 197 | 198 | <p className="text-lg font-red-hat leading-relaxed"> 199 | Now that we have eye tracking, we can make some cool things with it! I decided to use the user's gaze as a mouse and have them click with spacebar—kind of like how Apple Vision Pros have you look and pinch. Although I had the main functionality, it was far from finished. There were many considerations with making the experience as smooth and immersive as possible. 200 | </p> 201 | 202 | <div className="my-8"> 203 | <img 204 | src="/gazepage.jpg" 205 | alt="Main page" 206 | className="w-full max-w-2xl mx-auto rounded-lg shadow-lg" 207 | /> 208 | </div> 209 | 210 | <h2 className="text-4xl font-cor-gar mt-12 mb-6">The "Invisible" Mouse</h2> 211 | 212 | <p className="text-lg font-red-hat leading-relaxed"> 213 | Initially, the user could see "where" they were looking at through a red dot. 214 | </p> 215 | 216 | <div className="my-8"> 217 | <img 218 | src="/gazedebug.jpg" 219 | alt="Gaze page in debug mode" 220 | className="w-full max-w-2xl mx-auto rounded-lg shadow-lg" 221 | /> 222 | <p className="text-sm font-red-hat text-gray-400 text-center mt-2 italic"> 223 | Main page in debug mode. 224 | </p> 225 | </div> 226 | 227 | <p className="text-lg font-red-hat leading-relaxed"> 228 | This created some problems. First, the red dot was distracting, and users would unconsciously look at it instead of my buttons. Second, the red dot revealed how inaccurate the eye tracking was, which ruined the immersion. 229 | </p> 230 | 231 | <p className="text-lg font-red-hat leading-relaxed"> 232 | Ultimately, I decided to remove the "eye cursor" and also make the user's mouse invisible. It made you really feel like you were controlling the website with your eyes rather than moving a mouse around. You can turn on debug mode to see your eye cursor and mouse. 233 | </p> 234 | 235 | <h2 className="text-4xl font-cor-gar mt-12 mb-6">User feedback</h2> 236 | 237 | <p className="text-lg font-red-hat leading-relaxed"> 238 | Since we don't have a mouse, we need some way for the user to know they are looking at something. To do this… we track the user's gaze (how surprising). We hid the eye cursor, but we still have the x and y coordinates of the user's gaze. Each button component has checks to see if that gaze is within its borders. When the component detects the user is looking at it, it responds with a slight glow and pop. 239 | </p> 240 | 241 | <div className="my-8"> 242 | <img 243 | src="/inandout.gif" 244 | alt="Eye cursor going in and out" 245 | className="w-full max-w-2xl mx-auto rounded-lg shadow-lg" 246 | /> 247 | </div> 248 | 249 | <h2 className="text-4xl font-cor-gar mt-12 mb-6">Large UI</h2> 250 | 251 | <p className="text-lg font-red-hat leading-relaxed"> 252 | Admittedly, the eye tracking is not the best. You can really see how jittery it is with debug mode on. So I decided to make the UI huge. I also added a screen size restriction so the site is only usable on displays that meet a minimum size threshold (Sorry mobile users! It wouldn't work on your phone anyway). 253 | </p> 254 | 255 | <div className="my-8"> 256 | <img 257 | src="/jitter.gif" 258 | alt="Eye cursor jittering" 259 | className="w-full max-w-2xl mx-auto rounded-lg shadow-lg" 260 | /> 261 | <p className="text-sm font-red-hat text-gray-400 text-center mt-2 italic"> 262 | The large size of the button accounts for the jitteriness of the eye tracking. 263 | </p> 264 | </div> 265 | 266 | <h2 className="text-4xl font-cor-gar mt-12 mb-6">Conclusion</h2> 267 | 268 | <p className="text-lg font-red-hat leading-relaxed"> 269 | Those were a few details about Eyesite. If you are interested, you can see the source code. Small warning: this project was just a small demo and isn't a shining example of clean code or best practices. 270 | </p> 271 | 272 | <p className="text-lg font-red-hat leading-relaxed"> 273 | This was a really fun project to make, and super cool to use too. If you want to make your own computer vision project or improve this one, I encourage you to do so! You can find the project at{' '} 274 | <a href="https://github.com/akchro/eyesite" className="text-blue-400 hover:text-blue-300 underline"> 275 | https://github.com/akchro/eyesite 276 | </a>. 277 | </p> 278 | 279 | <div className="h-32"></div> {/* Extra space for scrolling demonstration */} 280 | </article> 281 | </div> 282 | </div> 283 | 284 | {/* Scroll Instructions at Bottom */} 285 | <div className="relative"> 286 | {/* Base gradient - always present */} 287 | <div className={`absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-blue-600/20 to-transparent pointer-events-none ${debugMode ? 'border-2' : ''}`} /> 288 | 289 | {/* Overlay gradient - fades in when scrolling */} 290 | <div 291 | className="absolute bottom-0 left-0 right-0 h-32 bg-gradient-to-t from-blue-600/40 to-transparent pointer-events-none transition-opacity duration-300" 292 | style={{ 293 | opacity: scrollSpeed !== 0 ? 1 : 0 294 | }} 295 | /> 296 | 297 | <div className="absolute bottom-4 left-1/2 transform -translate-x-1/2"> 298 | <p className="text-white text-3xl font-red-hat text-center bg-black/50 px-4 py-2 rounded"> 299 | Look to Scroll 300 | </p> 301 | </div> 302 | </div> 303 | 304 | {/* Exit Instructions */} 305 | <div className="absolute top-6 right-6"> 306 | <p className="text-white text-sm text-center font-red-hat px-4 py-2 rounded"> 307 | Press Spacebar to Exit 308 | </p> 309 | <p className="text-white text-sm text-center font-red-hat px-4 py-2 rounded"> 310 | Find the original blog at blog.andykhau.com 311 | </p> 312 | </div> 313 | 314 | </div> 315 | ); 316 | }; 317 | 318 | export default Blog; -------------------------------------------------------------------------------- /src/components/ClickableSquare.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRef, useState } from 'react'; 4 | import { useGazeClick } from './useGazeClick'; 5 | 6 | const ClickableSquare = () => { 7 | const squareRef = useRef(null); 8 | const [clickCount, setClickCount] = useState(0); 9 | const [isFlashing, setIsFlashing] = useState(false); 10 | 11 | const handleGazeClick = ({ gazeX, gazeY }) => { 12 | console.log(`Gaze click at: ${gazeX}, ${gazeY}`); 13 | setClickCount(prev => prev + 1); 14 | 15 | // Flash effect 16 | setIsFlashing(true); 17 | setTimeout(() => { 18 | setIsFlashing(false); 19 | }, 300); 20 | }; 21 | 22 | const { isGazeHovered, isClicked } = useGazeClick(squareRef, handleGazeClick); 23 | 24 | const getSquareColor = () => { 25 | if (isClicked || isFlashing) return 'bg-yellow-400'; // Flash yellow when clicked 26 | if (isGazeHovered) return 'bg-red-500'; // Red when hovered 27 | return 'bg-blue-500'; // Default blue 28 | }; 29 | 30 | const getSquareScale = () => { 31 | if (isClicked) return 'scale-110'; // Slightly larger when clicked 32 | if (isGazeHovered) return 'scale-105'; // Slightly larger when hovered 33 | return 'scale-100'; // Normal size 34 | }; 35 | 36 | return ( 37 | <div 38 | ref={squareRef} 39 | className={`w-[700px] h-80 border-2 border-gray-400 transition-all duration-200 ${getSquareColor()} ${getSquareScale()}`} 40 | style={{ 41 | position: 'absolute', 42 | top: '50%', 43 | left: '50%', 44 | transform: 'translate(-50%, -50%)', 45 | }} 46 | > 47 | <div className="text-white text-center mt-10 space-y-2"> 48 | <div className="text-lg font-bold"> 49 | {isClicked ? 'CLICKED!' : isGazeHovered ? 'Press SPACE to click!' : 'Look at me'} 50 | </div> 51 | <div className="text-sm"> 52 | Clicks: {clickCount} 53 | </div> 54 | {isGazeHovered && ( 55 | <div className="text-xs animate-pulse"> 56 | Press SPACEBAR to click 57 | </div> 58 | )} 59 | </div> 60 | </div> 61 | ); 62 | }; 63 | 64 | export default ClickableSquare; -------------------------------------------------------------------------------- /src/components/GazeClickWrapper.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useRef, cloneElement, useEffect, useState } from 'react'; 4 | import { useGazeClick } from './useGazeClick'; 5 | 6 | const GazeClickWrapper = ({ 7 | children, 8 | onGazeClick, 9 | onGazeHover, 10 | onGazeLeave, 11 | threshold = 20, 12 | className = '', 13 | style = {}, 14 | showClickEffect = true, 15 | clickEffectDuration = 200, 16 | ...props 17 | }) => { 18 | const elementRef = useRef(null); 19 | const [isFlashing, setIsFlashing] = useState(false); 20 | 21 | const handleGazeClick = (clickData) => { 22 | if (showClickEffect) { 23 | setIsFlashing(true); 24 | setTimeout(() => { 25 | setIsFlashing(false); 26 | }, clickEffectDuration); 27 | } 28 | 29 | if (onGazeClick) { 30 | onGazeClick(clickData); 31 | } 32 | }; 33 | 34 | const { isGazeHovered, isClicked } = useGazeClick(elementRef, handleGazeClick, threshold); 35 | 36 | // Handle gaze hover events 37 | useEffect(() => { 38 | if (isGazeHovered && onGazeHover) { 39 | onGazeHover(); 40 | } else if (!isGazeHovered && onGazeLeave) { 41 | onGazeLeave(); 42 | } 43 | }, [isGazeHovered, onGazeHover, onGazeLeave]); 44 | 45 | const getAdditionalProps = () => { 46 | const additionalProps = { 47 | 'data-gaze-hovered': isGazeHovered, 48 | 'data-gaze-clicked': isClicked, 49 | 'data-gaze-flashing': isFlashing, 50 | }; 51 | 52 | if (showClickEffect) { 53 | additionalProps.className = `${className} ${isFlashing ? 'gaze-click-flash' : ''}`; 54 | } 55 | 56 | return additionalProps; 57 | }; 58 | 59 | // If children is a single React element, clone it with the ref and additional props 60 | if (React.isValidElement(children)) { 61 | return cloneElement(children, { 62 | ref: elementRef, 63 | className: `${children.props.className || ''} ${className} ${isFlashing ? 'gaze-click-flash' : ''}`, 64 | style: { ...children.props.style, ...style }, 65 | ...getAdditionalProps(), 66 | ...props 67 | }); 68 | } 69 | 70 | // Otherwise, wrap in a div 71 | return ( 72 | <div 73 | ref={elementRef} 74 | className={`${className} ${isFlashing ? 'gaze-click-flash' : ''}`} 75 | style={style} 76 | {...getAdditionalProps()} 77 | {...props} 78 | > 79 | {children} 80 | </div> 81 | ); 82 | }; 83 | 84 | export default GazeClickWrapper; -------------------------------------------------------------------------------- /src/components/GazeWrapper.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import React, { useRef, cloneElement, useEffect } from 'react'; 4 | import { useGazeHover } from './useGazeHover'; 5 | 6 | const GazeWrapper = ({ 7 | children, 8 | onGazeEnter, 9 | onGazeLeave, 10 | threshold = 20, 11 | className = '', 12 | style = {}, 13 | ...props 14 | }) => { 15 | const elementRef = useRef(null); 16 | const isGazeHovered = useGazeHover(elementRef, threshold); 17 | 18 | // Handle gaze enter/leave events 19 | useEffect(() => { 20 | if (isGazeHovered && onGazeEnter) { 21 | onGazeEnter(); 22 | } else if (!isGazeHovered && onGazeLeave) { 23 | onGazeLeave(); 24 | } 25 | }, [isGazeHovered, onGazeEnter, onGazeLeave]); 26 | 27 | // If children is a single React element, clone it with the ref 28 | if (React.isValidElement(children)) { 29 | return cloneElement(children, { 30 | ref: elementRef, 31 | className: `${children.props.className || ''} ${className}`, 32 | style: { ...children.props.style, ...style }, 33 | 'data-gaze-hovered': isGazeHovered, 34 | ...props 35 | }); 36 | } 37 | 38 | // Otherwise, wrap in a div 39 | return ( 40 | <div 41 | ref={elementRef} 42 | className={className} 43 | style={style} 44 | data-gaze-hovered={isGazeHovered} 45 | {...props} 46 | > 47 | {children} 48 | </div> 49 | ); 50 | }; 51 | 52 | export default GazeWrapper; -------------------------------------------------------------------------------- /src/components/LandingScreen.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | import { Transition } from '@headlessui/react'; 5 | import { useWebGazer } from './webgazerProvider'; 6 | 7 | export default function LandingScreen({ children }) { 8 | const [showLanding, setShowLanding] = useState(true); 9 | const [minWaitingTime, setMinWaitingTime] = useState(false); 10 | const [cameraPermission, setCameraPermission] = useState('checking'); // 'checking', 'granted', 'denied', 'prompt' 11 | const [cameraError, setCameraError] = useState(null); 12 | const { isReady } = useWebGazer(); 13 | 14 | useEffect(() => { 15 | const timer = setTimeout(() => { 16 | setMinWaitingTime(true); 17 | }, 2000); 18 | 19 | return () => clearTimeout(timer); 20 | }, []); 21 | 22 | useEffect(() => { 23 | // Check camera permission on mount 24 | checkCameraPermission(); 25 | }, []); 26 | 27 | const checkCameraPermission = async () => { 28 | try { 29 | if (navigator.permissions) { 30 | const permission = await navigator.permissions.query({ name: 'camera' }); 31 | setCameraPermission(permission.state); 32 | 33 | // Listen for permission changes 34 | permission.onchange = () => { 35 | setCameraPermission(permission.state); 36 | }; 37 | } else { 38 | // Fallback: try to access camera directly 39 | setCameraPermission('prompt'); 40 | } 41 | } catch (error) { 42 | console.error('Error checking camera permission:', error); 43 | setCameraPermission('prompt'); 44 | } 45 | }; 46 | 47 | const requestCameraAccess = async () => { 48 | try { 49 | setCameraError(null); 50 | const stream = await navigator.mediaDevices.getUserMedia({ video: true }); 51 | 52 | // Stop the stream immediately - we just needed to request permission 53 | stream.getTracks().forEach(track => track.stop()); 54 | 55 | setCameraPermission('granted'); 56 | } catch (error) { 57 | console.error('Camera access denied:', error); 58 | setCameraError(error.message || 'Camera access was denied'); 59 | setCameraPermission('denied'); 60 | } 61 | }; 62 | 63 | useEffect(() => { 64 | // Hide landing screen when all conditions are met 65 | if (minWaitingTime && isReady && cameraPermission === 'granted') { 66 | setShowLanding(false); 67 | } 68 | }, [minWaitingTime, isReady, cameraPermission]); 69 | 70 | const renderCameraStatus = () => { 71 | switch (cameraPermission) { 72 | case 'checking': 73 | return ( 74 | <div className="text-center"> 75 | <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-4"></div> 76 | <p className="text-xl text-gray-300">Checking camera access...</p> 77 | </div> 78 | ); 79 | 80 | case 'prompt': 81 | case 'denied': 82 | return ( 83 | <div className="text-center max-w-md"> 84 | <div className="mb-6"> 85 | <svg className="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 86 | <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /> 87 | </svg> 88 | </div> 89 | <h2 className="text-2xl font-semibold text-white mb-4">Camera Access Required</h2> 90 | <p className="text-gray-300 mb-6"> 91 | Eyesite needs access to your camera to track your eye movements for calibration and gaze detection. 92 | </p> 93 | {cameraError && ( 94 | <div className="bg-red-900/50 border border-red-700 rounded-lg p-3 mb-4"> 95 | <p className="text-red-200 text-sm">{cameraError}</p> 96 | </div> 97 | )} 98 | <button 99 | onClick={requestCameraAccess} 100 | className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-lg transition-colors duration-200" 101 | > 102 | Enable Camera 103 | </button> 104 | <p className="text-sm text-gray-400 mt-4"> 105 | You may need to click "Allow" in your browser's permission dialog 106 | </p> 107 | </div> 108 | ); 109 | 110 | case 'granted': 111 | if (!minWaitingTime || !isReady) { 112 | return ( 113 | <div className="text-center"> 114 | <h2 className="text-7xl font-red-hat text-white mb-4">Eyesite</h2> 115 | <p className="text-gray-300 mb-4">Initializing eye tracking...</p> 116 | <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto"></div> 117 | </div> 118 | ); 119 | } 120 | break; 121 | 122 | default: 123 | return null; 124 | } 125 | }; 126 | 127 | return ( 128 | <> 129 | <Transition 130 | show={showLanding} 131 | as="div" 132 | leave="transition-opacity duration-500" 133 | leaveFrom="opacity-100" 134 | leaveTo="opacity-0" 135 | className="fixed inset-0 bg-gray-950 flex flex-col items-center justify-center z-50 p-8" 136 | > 137 | 138 | {renderCameraStatus()} 139 | </Transition> 140 | 141 | {!showLanding && children} 142 | </> 143 | ); 144 | } -------------------------------------------------------------------------------- /src/components/WrappedClickableSquare.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import GazeClickWrapper from './GazeClickWrapper'; 5 | 6 | const WrappedClickableSquare = () => { 7 | const [clickCount, setClickCount] = useState(0); 8 | const [isHovered, setIsHovered] = useState(false); 9 | 10 | const handleGazeClick = ({ gazeX, gazeY }) => { 11 | console.log(`Wrapped gaze click at: ${gazeX}, ${gazeY}`); 12 | setClickCount(prev => prev + 1); 13 | }; 14 | 15 | return ( 16 | <GazeClickWrapper 17 | onGazeClick={handleGazeClick} 18 | onGazeHover={() => setIsHovered(true)} 19 | onGazeLeave={() => setIsHovered(false)} 20 | style={{ 21 | position: 'absolute', 22 | top: '50%', 23 | left: '50%', 24 | transform: 'translate(-50%, -50%)', 25 | }} 26 | > 27 | <div 28 | className={`w-[500px] h-80 border-2 border-gray-400 transition-all duration-300 ${ 29 | isHovered ? 'bg-red-500' : 'bg-blue-500' 30 | }`} 31 | > 32 | <div className="text-white text-center mt-10 space-y-2"> 33 | <div className="text-lg font-bold"> 34 | {isHovered ? 'Press SPACE to click!' : 'Look at me'} 35 | </div> 36 | <div className="text-sm"> 37 | Wrapper Clicks: {clickCount} 38 | </div> 39 | {isHovered && ( 40 | <div className="spacebar-instruction"> 41 | SPACEBAR to click 42 | </div> 43 | )} 44 | </div> 45 | </div> 46 | </GazeClickWrapper> 47 | ); 48 | }; 49 | 50 | export default WrappedClickableSquare; -------------------------------------------------------------------------------- /src/components/WrappedSquare.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import GazeWrapper from './GazeWrapper'; 5 | 6 | const WrappedSquare = () => { 7 | const [isGazed, setIsGazed] = useState(false); 8 | 9 | return ( 10 | <GazeWrapper 11 | onGazeEnter={() => setIsGazed(true)} 12 | onGazeLeave={() => setIsGazed(false)} 13 | style={{ 14 | position: 'absolute', 15 | top: '50%', 16 | left: '50%', 17 | transform: 'translate(-50%, -50%)', 18 | }} 19 | > 20 | <div 21 | className={`w-[500px] h-80 border-2 border-gray-400 transition-colors duration-300 ${ 22 | isGazed ? 'bg-red-500' : 'bg-blue-500' 23 | }`} 24 | > 25 | <div className="text-white text-center mt-10"> 26 | {isGazed ? 'Looking!' : 'Look at me'} 27 | </div> 28 | </div> 29 | </GazeWrapper> 30 | ); 31 | }; 32 | 33 | export default WrappedSquare; -------------------------------------------------------------------------------- /src/components/calibrate.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useWebGazer } from "@/components/webgazerProvider"; 4 | import { useState, useEffect, useCallback } from 'react'; 5 | 6 | export default function Calibrate({calibrationComplete, setCalibrationComplete, calibrationPoints, setCalibrationPoints, introShown, setIntroShown}) { 7 | const { calibrateAt, isReady } = useWebGazer(); 8 | 9 | // Calibration state 10 | const [currentPointIndex, setCurrentPointIndex] = useState(0); 11 | const [pressCount, setPressCount] = useState(0); 12 | const [isTransitioning, setIsTransitioning] = useState(false); 13 | 14 | // Introduction sequence state 15 | const [introStep, setIntroStep] = useState(0); 16 | const [showCalibration, setShowCalibration] = useState(false); 17 | const [encouragementMessage, setEncouragementMessage] = useState(''); 18 | const [showEncouragement, setShowEncouragement] = useState(false); 19 | const [usedEncouragements, setUsedEncouragements] = useState([]); 20 | const [introFadingOut, setIntroFadingOut] = useState(false); 21 | const [calibrationFadingIn, setCalibrationFadingIn] = useState(false); 22 | const [calibrationFadingOut, setCalibrationFadingOut] = useState(false); 23 | 24 | // Introduction messages 25 | const introMessages = [ 26 | "Hello", 27 | "Let's calibrate", 28 | "Try not to blink", 29 | "Look at the point. Press spacebar to calibrate" 30 | ]; 31 | 32 | // Encouraging messages 33 | const encouragingWords = [ 34 | "Amazing.", "Incredible.", "Wonderful.", "Perfect.", "Excellent.", 35 | "Outstanding.", "Brilliant.", "Fantastic.", "Superb.", "Magnificent." 36 | ]; 37 | 38 | // Define calibration points in order 39 | const calibrationSequence = [ 40 | { id: 'Pt1', position: { top: '70px', left: '2vw' } }, 41 | { id: 'Pt2', position: { top: '70px', left: '50%', transform: 'translateX(-50%)' } }, 42 | { id: 'Pt3', position: { top: '70px', right: '2vw' } }, 43 | { id: 'Pt4', position: { top: '50%', left: '2vw', transform: 'translateY(-50%)' } }, 44 | { id: 'Pt6', position: { top: '50%', right: '2vw', transform: 'translateY(-50%)' } }, 45 | { id: 'Pt7', position: { bottom: '2vw', left: '2vw' } }, 46 | { id: 'Pt8', position: { bottom: '2vw', left: '50%', transform: 'translateX(-50%)' } }, 47 | { id: 'Pt9', position: { bottom: '2vw', right: '2vw' } }, 48 | { id: 'Pt5', position: { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' } }, // Center point last 49 | ]; 50 | 51 | const currentPoint = calibrationSequence[currentPointIndex]; 52 | 53 | // Introduction sequence effect 54 | useEffect(() => { 55 | if (!isReady) return; 56 | 57 | // If intro has already been shown, skip directly to calibration 58 | if (introShown) { 59 | setShowCalibration(true); 60 | setTimeout(() => { 61 | setCalibrationFadingIn(true); 62 | }, 50); 63 | return; 64 | } 65 | 66 | const timeouts = []; 67 | 68 | // Step through introduction messages 69 | introMessages.forEach((message, index) => { 70 | const timeout = setTimeout(() => { 71 | setIntroStep(index); 72 | }, index * 2500); // 2 seconds between each message 73 | timeouts.push(timeout); 74 | }); 75 | 76 | // Start fade out of final message 77 | const fadeOutTimeout = setTimeout(() => { 78 | setIntroFadingOut(true); 79 | }, (introMessages.length * 2500) - 500); // Start fade 500ms before showing calibration 80 | timeouts.push(fadeOutTimeout); 81 | 82 | // Show calibration after intro 83 | const finalTimeout = setTimeout(() => { 84 | setShowCalibration(true); 85 | // Mark intro as shown 86 | setIntroShown(true); 87 | // Start fade-in animation for calibration elements 88 | setTimeout(() => { 89 | setCalibrationFadingIn(true); 90 | }, 50); // Small delay to ensure showCalibration state is set 91 | }, introMessages.length * 2500); 92 | timeouts.push(finalTimeout); 93 | 94 | return () => timeouts.forEach(clearTimeout); 95 | }, [isReady, introShown, setIntroShown]); 96 | 97 | // Show encouragement after each point 98 | const showEncouragementMessage = () => { 99 | // Get available words (not used yet) 100 | let availableWords = encouragingWords.filter(word => !usedEncouragements.includes(word)); 101 | 102 | // If all words used, reset the list 103 | if (availableWords.length === 0) { 104 | availableWords = [...encouragingWords]; 105 | setUsedEncouragements([]); 106 | } 107 | 108 | const randomMessage = availableWords[Math.floor(Math.random() * availableWords.length)]; 109 | setEncouragementMessage(randomMessage); 110 | 111 | // Add to used list 112 | setUsedEncouragements(prev => [...prev, randomMessage]); 113 | 114 | // Fade in 115 | setShowEncouragement(true); 116 | 117 | // Fade out after 1 second 118 | setTimeout(() => { 119 | setShowEncouragement(false); 120 | }, 1000); 121 | }; 122 | 123 | // Handle spacebar press and R for restart 124 | const handleKeyPress = useCallback((event) => { 125 | // Handle R key for restart calibration 126 | if (event.code === 'KeyR' && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey) { 127 | event.preventDefault(); 128 | // Reset calibration state 129 | setCurrentPointIndex(0); 130 | setPressCount(0); 131 | setIsTransitioning(false); 132 | setCalibrationPoints({}); 133 | setCalibrationFadingOut(false); 134 | setCalibrationFadingIn(false); 135 | 136 | // Clear webgazer data 137 | if (window.webgazer) { 138 | window.webgazer.clearData(); 139 | } 140 | 141 | // Restart calibration (skip intro since it's already been shown) 142 | setShowCalibration(true); 143 | setTimeout(() => { 144 | setCalibrationFadingIn(true); 145 | }, 100); 146 | return; 147 | } 148 | 149 | // Handle spacebar for calibration 150 | if (event.code === 'Space' && !isTransitioning && showCalibration) { 151 | event.preventDefault(); 152 | 153 | if (currentPoint) { 154 | // Calculate point position for calibration - CALIBRATE ON EVERY PRESS 155 | const pointElement = document.getElementById(currentPoint.id); 156 | if (pointElement) { 157 | const rect = pointElement.getBoundingClientRect(); 158 | const x = rect.left + rect.width / 2; 159 | const y = rect.top + rect.height / 2; 160 | calibrateAt(x, y); // Call calibrateAt for every press 161 | } 162 | 163 | const newPressCount = pressCount + 1; 164 | setPressCount(newPressCount); 165 | 166 | // Move to next point after 5 presses 167 | if (newPressCount >= 5) { 168 | // Mark current point as calibrated 169 | setCalibrationPoints(prev => ({ 170 | ...prev, 171 | [currentPoint.id]: true 172 | })); 173 | 174 | // Show encouragement message 175 | showEncouragementMessage(); 176 | 177 | // Move to next point or complete calibration 178 | if (currentPointIndex < calibrationSequence.length - 1) { 179 | setIsTransitioning(true); 180 | 181 | // Smooth transition with fade out and fade in 182 | setTimeout(() => { 183 | setCurrentPointIndex(prev => prev + 1); 184 | setPressCount(0); 185 | setIsTransitioning(false); 186 | }, 1500); // Reduced wait time for encouragement message 187 | } else { 188 | // Calibration complete - start fade out 189 | setIsTransitioning(true); 190 | setCalibrationFadingOut(true); 191 | setTimeout(() => { 192 | setCalibrationComplete(true); 193 | }, 1500); // Wait for fade out animation 194 | } 195 | } 196 | } 197 | } 198 | }, [currentPoint, pressCount, currentPointIndex, isTransitioning, showCalibration, calibrateAt, setCalibrationPoints, setCalibrationComplete]); 199 | 200 | // Set up spacebar listener 201 | useEffect(() => { 202 | window.addEventListener('keydown', handleKeyPress); 203 | return () => window.removeEventListener('keydown', handleKeyPress); 204 | }, [handleKeyPress]); 205 | 206 | if (!isReady) return <p className="text-center mt-10 text-xl">Loading eye tracker...</p>; 207 | 208 | // Calculate opacity based on press count - but maintain minimum visibility to prevent jumping 209 | const pointOpacity = Math.max(0.6, Math.min((pressCount + 1) * 0.08 + 0.6, 1)); 210 | 211 | return ( 212 | <div className="relative w-full h-screen bg-gray-950 overflow-hidden"> 213 | {/* Introduction Sequence */} 214 | {!showCalibration && ( 215 | <div className="absolute inset-0 flex items-center justify-center z-30"> 216 | <div className="text-center"> 217 | {introMessages.map((message, index) => { 218 | const isCurrentStep = introStep === index; 219 | const isPastStep = introStep > index; 220 | const isFinalMessage = index === introMessages.length - 1; 221 | const shouldFadeOut = isFinalMessage && introFadingOut; 222 | 223 | return ( 224 | <div 225 | key={index} 226 | className={`absolute left-1/2 top-[45%] transform -translate-x-1/2 -translate-y-1/2 transition-all duration-1000 ease-out ${ 227 | shouldFadeOut 228 | ? 'opacity-0 scale-95' 229 | : isCurrentStep 230 | ? 'opacity-100 scale-100 translate-y-0' 231 | : isPastStep 232 | ? 'opacity-0 scale-95 -translate-y-8' 233 | : 'opacity-0 scale-105 translate-y-8' 234 | }`} 235 | style={{ 236 | fontSize: index === 0 ? '4rem' : index === 1 ? '3rem' : '2rem', 237 | fontWeight: index === 0 ? '300' : index === 1 ? '400' : '500', 238 | letterSpacing: index === 0 ? '0.1em' : index === 1 ? '0.05em' : '0.02em', 239 | color: index === 2 ? '#fbbf24' : '#ffffff' 240 | }} 241 | > 242 | {message} 243 | {index === 0 && <span className="animate-pulse">.</span>} 244 | {index === 2 && <span className="text-2xl ml-2">⚠️</span>} 245 | </div> 246 | ); 247 | })} 248 | </div> 249 | </div> 250 | )} 251 | 252 | {/* Encouragement Message */} 253 | <div className="absolute inset-0 flex items-center justify-center z-40 pointer-events-none"> 254 | <div 255 | className={`text-center transition-all duration-500 ease-in-out ${ 256 | showEncouragement ? 'opacity-100 scale-100 translate-y-0' : 'opacity-0 scale-90 translate-y-4' 257 | }`} 258 | style={{ 259 | fontSize: '3rem', 260 | fontWeight: '300', 261 | letterSpacing: '0.1em', 262 | color: '#10b981', 263 | textShadow: '0 0 30px rgba(16, 185, 129, 0.5)' 264 | }} 265 | > 266 | {encouragementMessage} 267 | </div> 268 | </div> 269 | 270 | {/* Progress Indicator */} 271 | {showCalibration && ( 272 | <div className={`absolute top-4 left-1/2 transform -translate-x-1/2 z-20 transition-all duration-1000 ease-out ${ 273 | calibrationFadingOut ? 'opacity-0 translate-y-4' : 274 | calibrationFadingIn ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-4' 275 | }`}> 276 | <div className="px-4 py-2 rounded-lg flex flex-col items-center "> 277 | <div className="text-white text-sm text-center font-red-hat"> 278 | Point {currentPointIndex + 1} of {calibrationSequence.length} | Press {pressCount}/5 279 | </div> 280 | <div className={'text-white text-sm center font-red-hat'}> 281 | Press R to restart 282 | </div> 283 | <div className={'text-white text-sm center font-red-hat'}> 284 | Look at the point. Press spacebar to calibrate 285 | </div> 286 | </div> 287 | </div> 288 | )} 289 | 290 | {/* Current calibration point */} 291 | {currentPoint && showCalibration && ( 292 | <div 293 | id={currentPoint.id} 294 | className={`absolute w-12 h-12 rounded-full bg-blue-600 border-4 border-blue-400 transition-all duration-1000 ease-in-out shadow-lg ${ 295 | calibrationFadingOut ? 'opacity-0 scale-75 translate-y-4' : 296 | isTransitioning ? 'opacity-0 scale-75' : 297 | calibrationFadingIn ? 'opacity-100 scale-100' : 'opacity-0 scale-75 -translate-y-4' 298 | }`} 299 | style={{ 300 | ...currentPoint.position, 301 | opacity: calibrationFadingOut ? 0 : (isTransitioning ? 0 : (calibrationFadingIn ? pointOpacity : 0)), 302 | transform: currentPoint.position.transform 303 | ? `${currentPoint.position.transform} ${ 304 | calibrationFadingOut ? 'scale(0.75) translateY(1rem)' : 305 | isTransitioning ? 'scale(0.75)' : 306 | calibrationFadingIn ? 'scale(1)' : 'scale(0.75) translateY(-1rem)' 307 | }` 308 | : calibrationFadingOut ? 'scale(0.75) translateY(1rem)' : 309 | isTransitioning ? 'scale(0.75)' : 310 | calibrationFadingIn ? 'scale(1)' : 'scale(0.75) translateY(-1rem)', 311 | boxShadow: `0 0 ${20 + pressCount * 8}px rgba(59, 130, 246, 0.8)`, 312 | zIndex: 10 313 | }} 314 | > 315 | {/* Inner progress indicator */} 316 | <div 317 | className="absolute inset-2 rounded-full bg-blue-300 transition-all duration-200" 318 | style={{ opacity: pressCount * 0.15 + 0.1 }} 319 | /> 320 | 321 | {/* Progress ring */} 322 | <svg className="absolute inset-0 w-full h-full transform -rotate-90" viewBox="0 0 48 48"> 323 | <circle 324 | cx="24" 325 | cy="24" 326 | r="20" 327 | stroke="rgba(59, 130, 246, 0.3)" 328 | strokeWidth="2" 329 | fill="transparent" 330 | /> 331 | <circle 332 | cx="24" 333 | cy="24" 334 | r="20" 335 | stroke="rgb(147, 197, 253)" 336 | strokeWidth="2" 337 | fill="transparent" 338 | strokeDasharray={`${(pressCount / 5) * 125.6} 125.6`} 339 | className="transition-all duration-200 ease-out" 340 | /> 341 | </svg> 342 | 343 | {/* Completion flash effect */} 344 | {pressCount === 5 && ( 345 | <div className="absolute inset-0 rounded-full bg-green-400 opacity-60 animate-ping" /> 346 | )} 347 | </div> 348 | )} 349 | 350 | {/* Completed points (fade out) */} 351 | {showCalibration && calibrationSequence.slice(0, currentPointIndex).map((point, index) => ( 352 | <div 353 | key={point.id} 354 | className={`absolute w-6 h-6 rounded-full bg-green-600 border-2 border-green-400 transition-all duration-1000 ${ 355 | calibrationFadingOut ? 'opacity-0 translate-y-4' : 'opacity-40' 356 | }`} 357 | style={{ 358 | ...point.position, 359 | transform: point.position.transform 360 | ? `${point.position.transform} translate(-50%, -50%)` 361 | : 'translate(-50%, -50%)', 362 | zIndex: 8 363 | }} 364 | /> 365 | ))} 366 | 367 | <style jsx>{` 368 | @keyframes ping { 369 | 75%, 100% { 370 | transform: scale(2); 371 | opacity: 0; 372 | } 373 | } 374 | `}</style> 375 | </div> 376 | ); 377 | } 378 | -------------------------------------------------------------------------------- /src/components/gaze.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import {useCallback, useEffect, useState} from 'react'; 4 | import { Transition } from '@headlessui/react'; 5 | import {useWebGazer} from './webgazerProvider'; 6 | import Calibrate from "@/components/calibrate"; 7 | import LookAndClick from "@/components/seeableTexts/lookAndClick"; 8 | import PlayAGame from "@/components/seeableTexts/playAGame"; 9 | import BlogButton from "@/components/seeableTexts/blogButton"; 10 | import Blog from "@/components/Blog"; 11 | import ScreenTooSmallWindow from "@/components/screenTooSmallWindow"; 12 | 13 | const Gaze = () => { 14 | const [calibrationComplete, setCalibrationComplete] = useState(false); 15 | const [calibrationPoints, setCalibrationPoints] = useState({}); 16 | const [debugMode, setDebugMode] = useState(false); 17 | const [introShown, setIntroShown] = useState(false); 18 | const { currentGaze, isReady, setVideoVisible, setPredictionPointsVisible } = useWebGazer(); 19 | 20 | // Game state management 21 | const [gameActive, setGameActive] = useState(false); 22 | const [gameProgress, setGameProgress] = useState(0); 23 | 24 | // Blog state management 25 | const [isReadingBlog, setIsReadingBlog] = useState(false); 26 | 27 | // Screen size validation 28 | const [screenSize, setScreenSize] = useState({ width: 0, height: 0 }); 29 | const [isScreenSizeValid, setIsScreenSizeValid] = useState(true); 30 | 31 | const MIN_WIDTH = 1200; 32 | const MIN_HEIGHT = 728; 33 | 34 | // Check screen size 35 | const checkScreenSize = useCallback(() => { 36 | const width = window.innerWidth; 37 | const height = window.innerHeight; 38 | setScreenSize({ width, height }); 39 | setIsScreenSizeValid(width >= MIN_WIDTH && height >= MIN_HEIGHT); 40 | }, []); 41 | 42 | // Handle screen resize 43 | useEffect(() => { 44 | checkScreenSize(); 45 | window.addEventListener('resize', checkScreenSize); 46 | return () => window.removeEventListener('resize', checkScreenSize); 47 | }, [checkScreenSize]); 48 | 49 | // Handle debug mode toggle and recalibration 50 | const handleKeyPress = useCallback((event) => { 51 | if (event.code === 'KeyD') { 52 | setDebugMode(prev => !prev); 53 | } 54 | // Only handle R for recalibration when calibration is complete and not reading blog 55 | // During calibration, the Calibrate component handles R key 56 | // During blog reading, spacebar is handled by Blog component for exit 57 | if (event.code === 'KeyR' && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey && calibrationComplete && !isReadingBlog) { 58 | handleRecalibrate(); 59 | } 60 | }, [calibrationComplete, isReadingBlog]); 61 | 62 | // Set up keyboard listener for debug mode 63 | useEffect(() => { 64 | window.addEventListener('keydown', handleKeyPress); 65 | return () => { 66 | window.removeEventListener('keydown', handleKeyPress); 67 | }; 68 | }, [handleKeyPress]); 69 | 70 | // Control video visibility based on calibration and debug mode 71 | useEffect(() => { 72 | if (setVideoVisible) { 73 | setVideoVisible(debugMode); 74 | } 75 | if (setPredictionPointsVisible) { 76 | setPredictionPointsVisible(debugMode) 77 | } 78 | }, [calibrationComplete, debugMode, setVideoVisible, setPredictionPointsVisible]); 79 | 80 | const handleRecalibrate = () => { 81 | if (window.webgazer) { 82 | window.webgazer.clearData(); 83 | setCalibrationPoints({}); 84 | setCalibrationComplete(false); 85 | // Keep introShown as true so intro doesn't replay 86 | // Video visibility will be handled by the useEffect above 87 | } 88 | }; 89 | 90 | const toggleDebugMode = () => { 91 | setDebugMode(prev => !prev); 92 | }; 93 | 94 | // Game handlers 95 | const handleGameStart = () => { 96 | setGameActive(true); 97 | setGameProgress(0); 98 | }; 99 | 100 | const handleGameEnd = () => { 101 | setGameActive(false); 102 | setGameProgress(0); 103 | }; 104 | 105 | const handleGameProgress = (progress) => { 106 | setGameProgress(progress); 107 | }; 108 | 109 | // Blog handlers 110 | const handleBlogOpen = () => { 111 | setIsReadingBlog(true); 112 | }; 113 | 114 | const handleBlogClose = () => { 115 | setIsReadingBlog(false); 116 | }; 117 | 118 | // Show screen size warning if dimensions are too small 119 | if (!isScreenSizeValid) { 120 | return ( 121 | <ScreenTooSmallWindow screenSize={screenSize} min_width={MIN_WIDTH} min_height={MIN_HEIGHT}/> 122 | ) 123 | } 124 | 125 | return ( 126 | <main className={debugMode ? '' : 'cursor-none'}> 127 | {/* Debug camera overlay */} 128 | {debugMode && calibrationComplete && ( 129 | <div className="debug-camera-overlay"> 130 | DEBUG CAM 131 | </div> 132 | )} 133 | 134 | {/* Calibration Screen with Transition */} 135 | <Transition 136 | show={!calibrationComplete} 137 | enter="transition-all duration-1000 ease-out" 138 | enterFrom="opacity-0" 139 | enterTo="opacity-100" 140 | leave="transition-all duration-1000 ease-out" 141 | leaveFrom="opacity-100" 142 | leaveTo="opacity-0" 143 | as="div" 144 | > 145 | <Calibrate 146 | calibrationComplete={calibrationComplete} 147 | setCalibrationComplete={setCalibrationComplete} 148 | calibrationPoints={calibrationPoints} 149 | setCalibrationPoints={setCalibrationPoints} 150 | introShown={introShown} 151 | setIntroShown={setIntroShown} 152 | /> 153 | </Transition> 154 | 155 | {/* Blog Screen with Transition */} 156 | <Transition 157 | show={calibrationComplete && isReadingBlog} 158 | enter="transition-all duration-500 ease-out" 159 | enterFrom="opacity-0" 160 | enterTo="opacity-100" 161 | leave="transition-all duration-500 ease-out" 162 | leaveFrom="opacity-100" 163 | leaveTo="opacity-0" 164 | as="div" 165 | > 166 | <Blog 167 | debugMode={debugMode} 168 | onExit={handleBlogClose} 169 | /> 170 | </Transition> 171 | 172 | {/* Main Screen with Transition */} 173 | <Transition 174 | show={calibrationComplete && !isReadingBlog} 175 | enter="transition-all duration-1000 ease-out delay-500" 176 | enterFrom="opacity-0 scale-95" 177 | enterTo="opacity-100 scale-100" 178 | leave="transition-all duration-500 ease-out" 179 | leaveFrom="opacity-100 scale-100" 180 | leaveTo="opacity-0 scale-95" 181 | as="div" 182 | > 183 | <div className="w-full h-screen relative bg-gray-950"> 184 | {/* PlayAGame component - always visible */} 185 | <PlayAGame 186 | debugMode={debugMode} 187 | gameActive={gameActive} 188 | onGameStart={handleGameStart} 189 | onGameEnd={handleGameEnd} 190 | onGameProgress={handleGameProgress} 191 | /> 192 | 193 | {/* Other interactive components - hidden during game */} 194 | <Transition 195 | show={!gameActive} 196 | enter="transition-opacity duration-500" 197 | enterFrom="opacity-0" 198 | enterTo="opacity-100" 199 | leave="transition-opacity duration-500" 200 | leaveFrom="opacity-100" 201 | leaveTo="opacity-0" 202 | > 203 | <div style={{ position: 'absolute', top: '20%', left: '18%' }}> 204 | <BlogButton 205 | debugMode={debugMode} 206 | onBlogClick={handleBlogOpen} 207 | /> 208 | </div> 209 | </Transition> 210 | 211 | <Transition 212 | show={!gameActive} 213 | enter="transition-opacity duration-500" 214 | enterFrom="opacity-0" 215 | enterTo="opacity-100" 216 | leave="transition-opacity duration-500" 217 | leaveFrom="opacity-100" 218 | leaveTo="opacity-0" 219 | > 220 | <div style={{ position: 'absolute', top: '20%', right: '20%' }}> 221 | <LookAndClick debugMode={debugMode}/> 222 | </div> 223 | </Transition> 224 | 225 | {/* Instructions */} 226 | <Transition 227 | show={!gameActive} 228 | enter="transition-opacity duration-500" 229 | enterFrom="opacity-0" 230 | enterTo="opacity-100" 231 | leave="transition-opacity duration-500" 232 | leaveFrom="opacity-100" 233 | leaveTo="opacity-0" 234 | > 235 | <div className="absolute bottom-20 right-10 p-4 rounded shadow max-w-lg flex flex-col"> 236 | <h3 className="font-bold font-red-hat mb-2 text-center text-white">Gaze Interaction Demo</h3> 237 | <p className="text-s font-red-hat text-gray-200 mt-2 text-center"> 238 | Use your eyes to control, press Spacebar to click. 239 | </p> 240 | <p className="text-s font-red-hat text-gray-200 mt-1 text-center font-medium"> 241 | Press D to toggle debug mode 242 | </p> 243 | <p className="text-s font-red-hat text-gray-200 mt-1 text-center font-medium"> 244 | Press R to recalibrate 245 | </p> 246 | {debugMode && ( 247 | <p className="text-xs text-yellow-600 mt-1 text-center font-bold"> 248 | 🔧 Debug Mode Active - Camera {calibrationComplete ? 'Visible' : 'Hidden'} 249 | </p> 250 | )} 251 | </div> 252 | </Transition> 253 | </div> 254 | </Transition> 255 | </main> 256 | ); 257 | }; 258 | 259 | export default Gaze; -------------------------------------------------------------------------------- /src/components/screenTooSmallWindow.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const ScreenTooSmallWindow = ({screenSize, min_width, min_height}) => { 4 | 5 | 6 | return ( 7 | <div className="fixed inset-0 bg-gray-900 flex items-center justify-center z-50"> 8 | <div className="text-center p-8 bg-gray-800 rounded-lg shadow-2xl max-w-md mx-4"> 9 | <div className="text-red-500 text-6xl mb-4">⚠️</div> 10 | <h1 className="text-2xl font-bold text-white mb-4">Screen Too Small</h1> 11 | <p className="text-gray-300 mb-4"> 12 | This application requires a minimum screen size to function properly. 13 | </p> 14 | <div className="text-sm text-gray-400 mb-4"> 15 | <p><strong>Minimum Required:</strong></p> 16 | <p>Width: {min_width}px | Height: {min_height}px</p> 17 | </div> 18 | <div className="text-sm text-gray-500"> 19 | <p><strong>Current Screen:</strong></p> 20 | <p>Width: {screenSize.width}px | Height: {screenSize.height}px</p> 21 | </div> 22 | <p className="text-yellow-400 text-sm mt-4"> 23 | Please resize your window or use a larger screen. 24 | </p> 25 | </div> 26 | </div> 27 | ); 28 | }; 29 | 30 | export default ScreenTooSmallWindow; -------------------------------------------------------------------------------- /src/components/seeableTexts/blogButton.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRef, useState } from 'react'; 4 | import { useGazeClick } from '../useGazeClick'; 5 | 6 | const BlogButton = ({ debugMode, onBlogClick }) => { 7 | const seeableTextRef = useRef(null); 8 | const [isActive, setIsActive] = useState(false); 9 | 10 | const handleGazeClick = ({ gazeX, gazeY }) => { 11 | // Flash effect 12 | setIsActive(true); 13 | setTimeout(() => { 14 | setIsActive(false); 15 | }, 1000); 16 | 17 | // Call the blog click handler if provided 18 | if (onBlogClick) { 19 | onBlogClick(); 20 | } 21 | }; 22 | 23 | const { isGazeHovered, isClicked } = useGazeClick(seeableTextRef, handleGazeClick); 24 | 25 | const getTextGlow = () => { 26 | if (isGazeHovered) { 27 | return { 28 | textShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 40px rgba(255, 255, 255, 0.4), 0 0 60px rgba(255, 255, 255, 0.2)' 29 | }; 30 | } 31 | return {}; 32 | }; 33 | 34 | return ( 35 | <div 36 | ref={seeableTextRef} 37 | className={`w-[550px] h-[400px] ${debugMode ? 'border-2' : ''}`} 38 | style={{ 39 | position: 'absolute', 40 | top: '50%', 41 | left: '50%', 42 | transform: 'translate(-50%, -50%)', 43 | }} 44 | > 45 | <div className={`text-white text-center h-full flex justify-center 46 | items-center mt-10 space-y-2 transition-all duration-500 ${isGazeHovered ? 'scale-110' : 'scale-100'}`}> 47 | <div 48 | className="text-8xl font-cor-gar transition-all duration-300" 49 | style={getTextGlow()} 50 | > 51 | {isActive ? 'Opening...' : 'Blog'} 52 | </div> 53 | </div> 54 | </div> 55 | ); 56 | }; 57 | 58 | export default BlogButton; -------------------------------------------------------------------------------- /src/components/seeableTexts/lookAndClick.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRef, useState } from 'react'; 4 | import { useGazeClick } from '../useGazeClick'; 5 | 6 | const LookAndClick = ({debugMode}) => { 7 | const seeableTextRef = useRef(null); 8 | const [isActive, setIsActive] = useState(false); 9 | 10 | const handleGazeClick = ({ gazeX, gazeY }) => { 11 | 12 | // Flash effect 13 | setIsActive(true); 14 | setTimeout(() => { 15 | setIsActive(false); 16 | }, 1000); 17 | }; 18 | 19 | const { isGazeHovered, isClicked } = useGazeClick(seeableTextRef, handleGazeClick); 20 | 21 | const getTextGlow = () => { 22 | if (isGazeHovered) { 23 | return { 24 | textShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 40px rgba(255, 255, 255, 0.4), 0 0 60px rgba(255, 255, 255, 0.2)' 25 | }; 26 | } 27 | return {}; 28 | }; 29 | 30 | return ( 31 | <div 32 | ref={seeableTextRef} 33 | className={`w-[550px] h-[400px] ${debugMode ? 'border-2' : ''}`} 34 | style={{ 35 | position: 'absolute', 36 | top: '50%', 37 | left: '50%', 38 | transform: 'translate(-50%, -50%)', 39 | }} 40 | > 41 | <div className={`text-white text-center h-full flex justify-center 42 | items-center mt-10 space-y-2 transition-all duration-500 ${isGazeHovered ? 'scale-110' : 'scale-100'}`}> 43 | <div 44 | className="text-8xl font-cor-gar transition-all duration-300" 45 | style={getTextGlow()} 46 | > 47 | {isActive ? 'Click!' : 'Look at me'} 48 | </div> 49 | </div> 50 | </div> 51 | ); 52 | }; 53 | 54 | export default LookAndClick; -------------------------------------------------------------------------------- /src/components/seeableTexts/playAGame.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRef, useState, useEffect } from 'react'; 4 | import { useGazeClick } from '../useGazeClick'; 5 | 6 | const PlayAGame = ({ debugMode, gameActive, onGameStart, onGameEnd, onGameProgress }) => { 7 | const seeableTextRef = useRef(null); 8 | const [isActive, setIsActive] = useState(false); 9 | const [clickCount, setClickCount] = useState(0); 10 | const [position, setPosition] = useState({ top: '50%', left: '10px' }); 11 | const [isMoving, setIsMoving] = useState(false); 12 | 13 | const handleGazeClick = ({ gazeX, gazeY }) => { 14 | if (!gameActive) { 15 | // Start the game 16 | onGameStart(); 17 | moveToRandomPosition(); 18 | return; 19 | } 20 | 21 | // Flash effect 22 | setIsActive(true); 23 | setTimeout(() => { 24 | setIsActive(false); 25 | }, 500); 26 | 27 | const newClickCount = clickCount + 1; 28 | setClickCount(newClickCount); 29 | onGameProgress(newClickCount); 30 | 31 | if (newClickCount >= 3) { 32 | // Game finished, return to center 33 | setTimeout(() => { 34 | setIsMoving(true); 35 | setPosition({ top: '50%', left: '10px' }); 36 | setTimeout(() => { 37 | setIsMoving(false); 38 | setClickCount(0); 39 | onGameEnd(); 40 | }, 1000); 41 | }, 500); 42 | } else { 43 | // Move to next random position 44 | setTimeout(() => { 45 | moveToRandomPosition(); 46 | }, 500); 47 | } 48 | }; 49 | 50 | const { isGazeHovered, isClicked } = useGazeClick(seeableTextRef, handleGazeClick); 51 | 52 | const moveToRandomPosition = () => { 53 | setIsMoving(true); 54 | 55 | // Calculate safe bounds to ensure component stays in viewable area 56 | // Component is 550px wide, 400px tall 57 | const margin = 50; 58 | const minTop = margin; 59 | const maxTop = window.innerHeight - 400 - margin; 60 | const minLeft = margin; 61 | const maxLeft = window.innerWidth - 550 - margin; 62 | 63 | const randomTop = Math.random() * (maxTop - minTop) + minTop; 64 | const randomLeft = Math.random() * (maxLeft - minLeft) + minLeft; 65 | 66 | setPosition({ 67 | top: `${randomTop}px`, 68 | left: `${randomLeft}px` 69 | }); 70 | 71 | setTimeout(() => { 72 | setIsMoving(false); 73 | }, 1000); 74 | }; 75 | 76 | const getTextGlow = () => { 77 | if (isGazeHovered) { 78 | return { 79 | textShadow: '0 0 20px rgba(255, 255, 255, 0.8), 0 0 40px rgba(255, 255, 255, 0.4), 0 0 60px rgba(255, 255, 255, 0.2)' 80 | }; 81 | } 82 | return {}; 83 | }; 84 | 85 | const getText = () => { 86 | if (!gameActive) { 87 | return "Let's play a game"; 88 | } 89 | if (isActive) { 90 | return 'Good!'; 91 | } 92 | if (clickCount === 0) { 93 | return 'Click me!'; 94 | } 95 | if (clickCount === 3) { 96 | return "Incredible." 97 | } 98 | return `${3 - clickCount} more!`; 99 | }; 100 | 101 | return ( 102 | <div 103 | ref={seeableTextRef} 104 | className={`w-[550px] h-[400px] ${debugMode ? 'border-2' : ''} transition-all duration-1000 ease-in-out ${ 105 | isMoving ? 'scale-95' : 'scale-100' 106 | }`} 107 | style={{ 108 | position: 'absolute', 109 | top: position.top, 110 | left: position.left, 111 | 112 | zIndex: gameActive ? 60 : 10, 113 | }} 114 | > 115 | <div className={`text-white text-center h-full flex justify-center 116 | items-center mt-10 space-y-2 transition-all duration-500 ${isGazeHovered ? 'scale-110' : 'scale-100'}`}> 117 | <div 118 | className="text-8xl font-cor-gar transition-all duration-300" 119 | style={getTextGlow()} 120 | > 121 | {getText()} 122 | </div> 123 | </div> 124 | </div> 125 | ); 126 | }; 127 | 128 | export default PlayAGame; -------------------------------------------------------------------------------- /src/components/square.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useRef } from 'react'; 4 | import { useGazeHover } from './useGazeHover'; 5 | 6 | const Square = () => { 7 | const squareRef = useRef(null); 8 | const isGazeHovered = useGazeHover(squareRef); 9 | 10 | return ( 11 | <div 12 | ref={squareRef} 13 | className={`w-[500px] h-80 border-2 border-gray-400 transition-colors duration-300 ${ 14 | isGazeHovered ? 'bg-red-500' : 'bg-blue-500' 15 | }`} 16 | style={{ 17 | position: 'absolute', 18 | top: '50%', 19 | left: '50%', 20 | transform: 'translate(-50%, -50%)', 21 | }} 22 | > 23 | <div className="text-white text-center mt-10 font-red-hat"> 24 | {isGazeHovered ? 'Looking!' : 'Look at me'} 25 | </div> 26 | </div> 27 | ); 28 | }; 29 | 30 | export default Square; -------------------------------------------------------------------------------- /src/components/useGazeClick.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect, useCallback } from 'react'; 4 | import { useWebGazer } from './webgazerProvider'; 5 | 6 | export const useGazeClick = (elementRef, onGazeClick, threshold = 20) => { 7 | const [isGazeHovered, setIsGazeHovered] = useState(false); 8 | const [isClicked, setIsClicked] = useState(false); 9 | const { isReady, addGazeListener, currentGaze } = useWebGazer(); 10 | 11 | // Check if gaze is within element bounds 12 | const checkGazePosition = useCallback((data) => { 13 | if (!data || !elementRef.current) return; 14 | 15 | const rect = elementRef.current.getBoundingClientRect(); 16 | const { x, y } = data; 17 | 18 | const isWithinBounds = 19 | x >= rect.left - threshold && 20 | x <= rect.right + threshold && 21 | y >= rect.top - threshold && 22 | y <= rect.bottom + threshold; 23 | 24 | setIsGazeHovered(isWithinBounds); 25 | }, [elementRef, threshold]); 26 | 27 | // Handle spacebar press for gaze clicking 28 | const handleKeyPress = useCallback((event) => { 29 | if (event.code === 'Space' && isGazeHovered && onGazeClick) { 30 | event.preventDefault(); // Prevent page scroll 31 | 32 | // Simulate click effect 33 | setIsClicked(true); 34 | 35 | // Call the click handler 36 | onGazeClick({ 37 | gazeX: currentGaze.x, 38 | gazeY: currentGaze.y, 39 | element: elementRef.current 40 | }); 41 | 42 | // Reset click state after animation 43 | setTimeout(() => { 44 | setIsClicked(false); 45 | }, 200); 46 | } 47 | }, [isGazeHovered, onGazeClick, currentGaze, elementRef]); 48 | 49 | // Set up gaze listener 50 | useEffect(() => { 51 | if (!isReady || !elementRef.current || !addGazeListener) return; 52 | 53 | const removeListener = addGazeListener(checkGazePosition); 54 | return removeListener; 55 | }, [isReady, elementRef, addGazeListener, checkGazePosition]); 56 | 57 | // Set up keyboard listener 58 | useEffect(() => { 59 | window.addEventListener('keydown', handleKeyPress); 60 | return () => { 61 | window.removeEventListener('keydown', handleKeyPress); 62 | }; 63 | }, [handleKeyPress]); 64 | 65 | return { 66 | isGazeHovered, 67 | isClicked 68 | }; 69 | }; -------------------------------------------------------------------------------- /src/components/useGazeHover.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState, useEffect } from 'react'; 4 | import { useWebGazer } from './webgazerProvider'; 5 | 6 | export const useGazeHover = (elementRef, threshold = 20) => { 7 | const [isHovered, setIsHovered] = useState(false); 8 | const { isReady, addGazeListener } = useWebGazer(); 9 | 10 | useEffect(() => { 11 | if (!isReady || !elementRef.current || !addGazeListener) return; 12 | 13 | const checkGazePosition = (data) => { 14 | if (!data || !elementRef.current) return; 15 | 16 | const rect = elementRef.current.getBoundingClientRect(); 17 | const { x, y } = data; 18 | 19 | // Check if gaze is within the element bounds with a threshold 20 | const isWithinBounds = 21 | x >= rect.left - threshold && 22 | x <= rect.right + threshold && 23 | y >= rect.top - threshold && 24 | y <= rect.bottom + threshold; 25 | 26 | setIsHovered(isWithinBounds); 27 | }; 28 | 29 | // Add this component's gaze listener 30 | const removeListener = addGazeListener(checkGazePosition); 31 | 32 | return () => { 33 | // Clean up this listener 34 | removeListener(); 35 | }; 36 | }, [isReady, elementRef, threshold, addGazeListener]); 37 | 38 | return isHovered; 39 | }; -------------------------------------------------------------------------------- /src/components/webgazerProvider.js: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createContext, useContext, useEffect, useRef, useState } from 'react'; 4 | 5 | const WebGazerContext = createContext(null); 6 | 7 | export function WebgazerProvider({ children }) { 8 | const webgazerRef = useRef(null); 9 | const [isReady, setIsReady] = useState(false); 10 | const [currentGaze, setCurrentGaze] = useState({ x: 0, y: 0 }); 11 | const gazeListenersRef = useRef(new Set()); 12 | 13 | useEffect(() => { 14 | let instance; 15 | (async () => { 16 | try { 17 | const webgazerMod = await import('webgazer'); 18 | // assign globally for any internals that expect window.webgazer: 19 | window.webgazer = webgazerMod.default; 20 | instance = webgazerMod.default; 21 | 22 | // Configure WebGazer 23 | instance 24 | .setGazeListener((data) => { 25 | if (data) { 26 | setCurrentGaze({ x: data.x, y: data.y }); 27 | // Call all registered listeners 28 | gazeListenersRef.current.forEach(listener => { 29 | try { 30 | listener(data); 31 | } catch (error) { 32 | console.error('Error in gaze listener:', error); 33 | } 34 | }); 35 | } 36 | }) 37 | .begin(); 38 | 39 | // Set WebGazer options 40 | instance.showVideo(false); // Hide video initially 41 | instance.showPredictionPoints(false); // Hide prediction points 42 | instance.showFaceOverlay(false) // Hide face overlay initially 43 | instance.showFaceFeedbackBox(false) // Hide boundary box initially 44 | 45 | // Store the instance 46 | webgazerRef.current = instance; 47 | setIsReady(true); 48 | 49 | console.log("WebGazer initialized successfully"); 50 | } catch (error) { 51 | console.error("Error initializing WebGazer:", error); 52 | } 53 | })(); 54 | 55 | return () => { 56 | if (instance) { 57 | try { 58 | instance.end(); 59 | } catch (error) { 60 | console.error("Error ending WebGazer:", error); 61 | } 62 | } 63 | }; 64 | }, []); 65 | 66 | // Function to add gaze listeners 67 | const addGazeListener = (listener) => { 68 | gazeListenersRef.current.add(listener); 69 | return () => { 70 | gazeListenersRef.current.delete(listener); 71 | }; 72 | }; 73 | 74 | // Function to control video display 75 | const setVideoVisible = (visible) => { 76 | if (webgazerRef.current) { 77 | try { 78 | webgazerRef.current.showVideo(visible); 79 | webgazerRef.current.showFaceOverlay(visible) 80 | webgazerRef.current.showFaceFeedbackBox(visible) 81 | console.log(`Video display set to: ${visible}`); 82 | } catch (error) { 83 | console.error("Error controlling video display:", error); 84 | } 85 | } 86 | }; 87 | 88 | // Function to control prediction points 89 | const setPredictionPointsVisible = (visible) => { 90 | if (webgazerRef.current) { 91 | try { 92 | webgazerRef.current.showPredictionPoints(visible); 93 | console.log(`Prediction points set to: ${visible}`); 94 | } catch (error) { 95 | console.error("Error controlling prediction points:", error); 96 | } 97 | } 98 | }; 99 | 100 | // Function to apply/remove Kalman filter 101 | const setKalmanFilter = (enabled) => { 102 | if (webgazerRef.current) { 103 | try { 104 | webgazerRef.current.applyKalmanFilter(enabled); 105 | console.log(`Kalman filter set to: ${enabled}`); 106 | } catch (error) { 107 | console.error("Error controlling Kalman filter:", error); 108 | } 109 | } 110 | }; 111 | 112 | // Expose whatever helpers you need, e.g. calibration dots: 113 | const calibrateAt = (x, y) => { 114 | if (!webgazerRef.current) { 115 | console.warn("WebGazer not initialized yet"); 116 | return; 117 | } 118 | 119 | try { 120 | // Add data point for calibration 121 | webgazerRef.current.recordScreenPosition(x, y, 'click'); 122 | console.log(`Calibration point added at: ${x}, ${y}`); 123 | } catch (error) { 124 | console.error("Error during calibration:", error); 125 | } 126 | }; 127 | 128 | return ( 129 | <WebGazerContext.Provider value={{ 130 | calibrateAt, 131 | instance: webgazerRef.current, 132 | isReady, 133 | currentGaze, 134 | addGazeListener, 135 | setVideoVisible, 136 | setPredictionPointsVisible, 137 | setKalmanFilter 138 | }}> 139 | {children} 140 | </WebGazerContext.Provider> 141 | ); 142 | } 143 | 144 | // custom hook for convenience 145 | export const useWebGazer = () => { 146 | const ctx = useContext(WebGazerContext); 147 | if (!ctx) throw new Error("useWebGazer must be inside WebGazerProvider"); 148 | return ctx; 149 | }; 150 | -------------------------------------------------------------------------------- /tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: [ 4 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 5 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 6 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 7 | ], 8 | theme: { 9 | extend: { 10 | colors: { 11 | background: "var(--background)", 12 | foreground: "var(--foreground)", 13 | }, 14 | keyframes: { 15 | grow: { 16 | '0%': { transform: 'scale(1)' }, 17 | '100%': { transform: 'scale(1.2)' }, 18 | }, 19 | }, 20 | animation: { 21 | 'grow-once': 'grow 5s ease-in forwards', 22 | }, 23 | fontFamily: { 24 | 'red-hat': ['var(--font-red-hat)'], 25 | 'cor-gar': ['var(--font-cormorant-garamond)'] 26 | } 27 | }, 28 | }, 29 | plugins: [], 30 | }; 31 | --------------------------------------------------------------------------------