1
0
Fork 0

Added the proper study mode (and freestyle mode so I can continue to test)

This commit is contained in:
Atridad Lahiji 2025-03-24 21:19:59 -06:00
parent 0ad8ca09c2
commit b4a104a860
Signed by: atridad
SSH key fingerprint: SHA256:LGomp8Opq0jz+7kbwNcdfTcuaLRb5Nh0k5AchDDb438
11 changed files with 1137 additions and 496 deletions

View file

@ -14,16 +14,16 @@
"react-dom": "^19.0.0" "react-dom": "^19.0.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.21.0", "@eslint/js": "^9.23.0",
"@types/react": "^19.0.10", "@types/react": "^19.0.12",
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.21.0", "eslint": "^9.23.0",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0", "globals": "^16.0.0",
"typescript": "~5.7.3", "typescript": "~5.8.2",
"typescript-eslint": "^8.25.0", "typescript-eslint": "^8.28.0",
"vite": "^6.1.1" "vite": "^6.2.3"
} }
} }

835
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

View file

@ -45,9 +45,6 @@
height: 10000px; height: 10000px;
transform-origin: 0 0; transform-origin: 0 0;
background-color: white; background-color: white;
background-image: linear-gradient(#ddd 1px, transparent 1px),
linear-gradient(90deg, #ddd 1px, transparent 1px);
background-size: 100px 100px;
} }
.target { .target {
@ -126,4 +123,152 @@ body,
.reset-button:hover { .reset-button:hover {
background: #fff5f5; background: #fff5f5;
border-color: #ef5350; border-color: #ef5350;
}
/* Preset Selection */
.preset-selection {
margin-bottom: 20px;
}
.preset-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.preset-button {
padding: 8px 16px;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.preset-button.active {
background: #646cff;
color: white;
border-color: #646cff;
}
.preset-button:hover:not(.active) {
background: #f5f5f5;
border-color: #646cff;
}
/* Mode Selection */
.mode-selection {
margin-bottom: 20px;
}
.radio-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.radio-group label {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
/* Break Screen */
.break-screen {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.break-container {
background-color: white;
padding: 30px;
border-radius: 10px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
max-width: 500px;
text-align: center;
}
.break-container h2 {
margin-top: 0;
color: #213547;
}
.break-container button {
margin-top: 20px;
padding: 10px 20px;
background-color: #646cff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: all 0.2s;
}
.break-container button:hover {
background-color: #4e57cc;
}
/* Resize Warning Popup */
.resize-warning {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2000;
background-color: rgba(0, 0, 0, 0.5);
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
animation: fadeIn 0.3s ease-out;
}
.resize-warning-content {
background-color: white;
padding: 20px 30px;
border-radius: 10px;
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
max-width: 400px;
text-align: center;
}
.resize-warning-content h3 {
margin-top: 0;
color: #213547;
}
.resize-warning-content button {
margin-top: 15px;
padding: 8px 20px;
background-color: #646cff;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.resize-warning-content button:hover {
background-color: #4e57cc;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
} }

View file

@ -10,6 +10,8 @@ import {
AccelerationSettings, AccelerationSettings,
Round, Round,
CONSTANTS, CONSTANTS,
ZoomPreset,
AppMode,
} from './types'; } from './types';
import { generateTargets, getRandomTarget } from './utils/target'; import { generateTargets, getRandomTarget } from './utils/target';
import { import {
@ -35,6 +37,13 @@ function App() {
const [zoomLevel, setZoomLevel] = useState<number>(CONSTANTS.ZOOM_LEVELS.default); const [zoomLevel, setZoomLevel] = useState<number>(CONSTANTS.ZOOM_LEVELS.default);
const [stats, setStats] = useState<Stats>(createInitialStats()); const [stats, setStats] = useState<Stats>(createInitialStats());
// Study mode state
const [mode, setMode] = useState<AppMode>('freestyle');
const [currentPreset, setCurrentPreset] = useState<ZoomPreset>('A');
const [presetsCompleted, setPresetsCompleted] = useState<ZoomPreset[]>([]);
const [targetCount, setTargetCount] = useState(0);
const [studyComplete, setStudyComplete] = useState(false);
// Load settings from localStorage or use defaults // Load settings from localStorage or use defaults
const [settings, setSettings] = useState<AccelerationSettings>(() => { const [settings, setSettings] = useState<AccelerationSettings>(() => {
const savedState = loadState(); const savedState = loadState();
@ -68,6 +77,9 @@ function App() {
const touchStartDistance = useRef<number>(0); const touchStartDistance = useRef<number>(0);
const wasReset = useRef(false); const wasReset = useRef(false);
// Add a new state for the resize warning popup
const [showResizeWarning, setShowResizeWarning] = useState(false);
useEffect(() => { useEffect(() => {
const savedState = loadState(); const savedState = loadState();
if (savedState?.stats) { if (savedState?.stats) {
@ -96,9 +108,51 @@ function App() {
const initialZoom = 0.2; const initialZoom = 0.2;
setZoomLevel(initialZoom); setZoomLevel(initialZoom);
setViewportOffset(calculateInitialViewport(initialZoom)); setViewportOffset(calculateInitialViewport(initialZoom));
setStats(previous => ({ ...previous, startTime: Date.now() })); setStats(createInitialStats());
setTargetCount(0);
setPresetsCompleted([]);
setStudyComplete(false);
}; };
// Apply preset when it changes
useEffect(() => {
if (currentPreset) {
setSettings(prev => ({
...prev,
enabled: true,
preset: currentPreset,
accelerationCurve: [...CONSTANTS.PRESETS[currentPreset]]
}));
}
}, [currentPreset]);
// Handle moving to next preset in study mode
useEffect(() => {
if (mode !== 'study') return;
if (targetCount >= CONSTANTS.STUDY_TARGETS_PER_PRESET) {
// Add current preset to completed list
if (!presetsCompleted.includes(currentPreset)) {
setPresetsCompleted(prev => [...prev, currentPreset]);
}
// Directly move to the next preset without showing break screen
const allPresets: ZoomPreset[] = ['A', 'B', 'C', 'D'];
const currentIndex = allPresets.indexOf(currentPreset);
if (currentIndex < allPresets.length - 1) {
// Move to next preset
const nextPreset = allPresets[currentIndex + 1];
setCurrentPreset(nextPreset);
setTargetCount(0);
} else {
// All presets completed
setStudyComplete(true);
}
}
}, [targetCount, currentPreset, mode, presetsCompleted]);
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current;
if (!container) return; if (!container) return;
@ -115,6 +169,36 @@ function App() {
const worldY = viewportOffset.y + cursorY / zoomLevel; const worldY = viewportOffset.y + cursorY / zoomLevel;
const scrollAmount = processWheelDelta(-event.deltaY); const scrollAmount = processWheelDelta(-event.deltaY);
const now = Date.now();
// Determine zoom direction (positive scrollAmount = zoom in, negative = zoom out)
const direction = scrollAmount > 0 ? 'in' : 'out';
// Update zoom timing stats
setStats(previous => {
// If we're already zooming in the same direction, add to the time
if (previous.isZooming && previous.zoomDirection === direction && previous.lastZoomTime) {
const timeDelta = now - previous.lastZoomTime;
// Only count if it's a reasonable time (less than 500ms between events)
const shouldCount = timeDelta < 500;
return {
...previous,
lastZoomTime: now,
zoomInTime: previous.zoomInTime + (direction === 'in' && shouldCount ? timeDelta : 0),
zoomOutTime: previous.zoomOutTime + (direction === 'out' && shouldCount ? timeDelta : 0),
zoomDirection: direction
};
}
// Otherwise start new zoom tracking
return {
...previous,
isZooming: true,
lastZoomTime: now,
zoomDirection: direction
};
});
if (isFinite(scrollAmount)) { if (isFinite(scrollAmount)) {
zoomSpeedSamples.current = updateWheelSamples(zoomSpeedSamples.current, scrollAmount); zoomSpeedSamples.current = updateWheelSamples(zoomSpeedSamples.current, scrollAmount);
@ -149,8 +233,29 @@ function App() {
} }
}; };
// Stop zoom timing when wheel events stop
const handleZoomEnd = () => {
setStats(previous => ({
...previous,
isZooming: false,
lastZoomTime: 0,
zoomDirection: null
}));
};
container.addEventListener('wheel', handleWheelEvent, { passive: false }); container.addEventListener('wheel', handleWheelEvent, { passive: false });
return () => container.removeEventListener('wheel', handleWheelEvent);
// Add a listener to detect when zooming stops (after 300ms of no wheel events)
let zoomEndTimer: number;
container.addEventListener('wheel', () => {
clearTimeout(zoomEndTimer);
zoomEndTimer = window.setTimeout(handleZoomEnd, 300);
});
return () => {
container.removeEventListener('wheel', handleWheelEvent);
clearTimeout(zoomEndTimer);
};
}, [zoomLevel, viewportOffset, settings]); }, [zoomLevel, viewportOffset, settings]);
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => { const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
@ -210,7 +315,7 @@ function App() {
}; };
const handleClick = (event: React.MouseEvent) => { const handleClick = (event: React.MouseEvent) => {
if (!containerRef.current || currentTarget === null) return; if (!containerRef.current || currentTarget === null || studyComplete) return;
// If this was a drag operation or very slow click (likely a drag attempt), ignore it // If this was a drag operation or very slow click (likely a drag attempt), ignore it
if (isDragging.current || Date.now() - dragStartTime.current > 200) { if (isDragging.current || Date.now() - dragStartTime.current > 200) {
@ -228,14 +333,41 @@ function App() {
if (distanceToTarget <= target.radius) { if (distanceToTarget <= target.radius) {
const timeElapsed = Date.now() - stats.startTime; const timeElapsed = Date.now() - stats.startTime;
setRounds(previous => [...previous, createNewRound(timeElapsed, stats.misclicks, settings)]);
// Create and save the round with zoom timing data
const newRound = createNewRound(
timeElapsed,
stats.misclicks,
{
...settings,
preset: mode === 'study' ? currentPreset : settings.preset
},
stats.zoomInTime,
stats.zoomOutTime
);
setRounds(previous => [...previous, newRound]);
// Update stats and reset zoom timing data
setStats({ setStats({
startTime: Date.now(), startTime: Date.now(),
misclicks: 0, misclicks: 0,
targetsHit: stats.targetsHit + 1, targetsHit: stats.targetsHit + 1,
times: [...stats.times, timeElapsed], times: [...stats.times, timeElapsed],
zoomInTime: 0,
zoomOutTime: 0,
lastZoomTime: 0,
isZooming: false,
zoomDirection: null
}); });
// Set next target
setCurrentTarget(getRandomTarget(currentTarget, CONSTANTS.TARGET_COUNT)); setCurrentTarget(getRandomTarget(currentTarget, CONSTANTS.TARGET_COUNT));
// If in study mode, increment target count
if (mode === 'study') {
setTargetCount(prev => prev + 1);
}
} else { } else {
setStats(previous => ({ setStats(previous => ({
...previous, ...previous,
@ -249,80 +381,106 @@ function App() {
setRounds([]); setRounds([]);
setStats(createInitialStats()); setStats(createInitialStats());
setSettings(createDefaultSettings()); setSettings(createDefaultSettings());
initializeGame();
wasReset.current = true; wasReset.current = true;
initializeGame();
// Close any open panels
setShowSettings(false);
setShowRounds(false);
// Show the initial instructions screen
setShowInstructions(true); setShowInstructions(true);
} }
}; };
const handleModeChange = (newMode: AppMode) => {
// Reset data when changing modes
if (newMode !== mode) {
if (window.confirm('Changing modes will reset your current data. Continue?')) {
setMode(newMode);
setRounds([]);
setStats(createInitialStats());
// Reset study mode stats when changing modes
if (newMode === 'study') {
setTargetCount(0);
setPresetsCompleted([]);
setCurrentPreset('A');
setStudyComplete(false);
}
// Reset the game
initializeGame();
}
} else {
setMode(newMode);
}
};
// Calculate average time
const averageTime = calculateAverageTime(stats.times);
// Add a resize handler to regenerate targets when window size changes
useEffect(() => {
let resizeTimer: number;
const handleResize = () => {
// Don't regenerate if in study mode to avoid disrupting the test
if (mode !== 'study') {
clearTimeout(resizeTimer);
resizeTimer = window.setTimeout(() => {
const newTargets = generateTargets();
setTargets(newTargets);
// If we're targeting something, make sure it's still valid
if (currentTarget !== null) {
setCurrentTarget(getRandomTarget(currentTarget, CONSTANTS.TARGET_COUNT));
}
// Show resize warning
setShowResizeWarning(true);
}, 500); // Add a delay to detect when resizing is "done"
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
clearTimeout(resizeTimer);
};
}, [currentTarget, mode]);
return ( return (
<div className="app"> <div className="app">
<div className="controls">
<button
className="settings-toggle"
onClick={() => setShowSettings(!showSettings)}
>
Settings
</button>
<button
className="rounds-toggle"
onClick={() => setShowRounds(!showRounds)}
>
📊 History
</button>
<button
className="export-button"
onClick={() => exportToCSV(rounds)}
disabled={rounds.length === 0}
>
📥 Export CSV
</button>
<button
className="reset-button"
onClick={handleReset}
>
🔄 Reset
</button>
</div>
<Settings
settings={settings}
onSettingsChange={setSettings}
visible={showSettings}
onClose={() => setShowSettings(false)}
/>
<RoundsHistory
rounds={rounds}
visible={showRounds}
onClose={() => setShowRounds(false)}
/>
<Instructions
visible={showInstructions}
onClose={() => setShowInstructions(false)}
/>
<div className="stats"> <div className="stats">
<span>Targets: {stats.targetsHit}</span> <span>
<span>Misclicks: {stats.misclicks}</span> <strong>Targets hit:</strong> {stats.targetsHit}
<span>Avg Time: {calculateAverageTime(stats.times)} ms</span> </span>
<span>Zoom: {zoomLevel.toFixed(2)}x</span> <span>
<strong>Average time:</strong>{' '}
{averageTime ? `${averageTime.toFixed(2)}ms` : '-'}
</span>
<span>
<strong>Misclicks:</strong> {stats.misclicks}
</span>
{mode === 'study' && (
<span>
<strong>Preset:</strong> {currentPreset} ({targetCount}/{CONSTANTS.STUDY_TARGETS_PER_PRESET})
</span>
)}
</div> </div>
<div <div
ref={containerRef} ref={containerRef}
className="container" className="container"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={handleClick}
onMouseMove={handleMouseMove} onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onClick={handleClick}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
> >
<div <div
className="content" className="content"
@ -345,6 +503,82 @@ function App() {
))} ))}
</div> </div>
</div> </div>
<div className="controls">
<button className="settings-toggle" onClick={() => setShowSettings(true)}>
<span></span> Settings
</button>
<button className="rounds-toggle" onClick={() => setShowRounds(true)}>
<span>📊</span> History
</button>
<button
className="export-button"
onClick={() => exportToCSV(rounds)}
disabled={rounds.length === 0}
>
<span>📤</span> Export Data
</button>
<button className="reset-button" onClick={handleReset}>
<span>🔄</span> Reset
</button>
</div>
<Settings
settings={settings}
onSettingsChange={setSettings}
mode={mode}
onModeChange={handleModeChange}
visible={showSettings}
onClose={() => setShowSettings(false)}
/>
<RoundsHistory
rounds={rounds}
visible={showRounds}
onClose={() => setShowRounds(false)}
/>
<Instructions
visible={showInstructions}
onClose={() => setShowInstructions(false)}
onModeSelect={handleModeChange}
/>
{studyComplete && (
<div className="break-screen">
<div className="break-container">
<h2>Study Complete!</h2>
<p>You've completed all presets in the study (100 trials). Thank you for participating!</p>
<p>You can now export your data or continue experimenting in freestyle mode.</p>
<div className="button-group">
<button onClick={() => {
setStudyComplete(false);
setMode('freestyle');
}}>
Switch to Freestyle Mode
</button>
<button
onClick={() => exportToCSV(rounds)}
disabled={rounds.length === 0}
style={{ marginLeft: '10px' }}
>
Export Data
</button>
</div>
</div>
</div>
)}
{/* Resize warning popup */}
{showResizeWarning && (
<div className="resize-warning">
<div className="resize-warning-content">
<h3>Window Resized</h3>
<p>Target positions have been reset to match your new window size.</p>
<button onClick={() => setShowResizeWarning(false)}>Got it</button>
</div>
</div>
)}
</div> </div>
); );
} }

View file

@ -15,10 +15,12 @@
background: white; background: white;
border-radius: 12px; border-radius: 12px;
padding: 30px; padding: 30px;
max-width: 500px; max-width: 600px;
width: 90%; width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
position: relative; position: relative;
max-height: 90vh;
overflow-y: auto;
} }
.instructions-header { .instructions-header {
@ -39,6 +41,12 @@
line-height: 1.6; line-height: 1.6;
} }
.instructions-content h3 {
margin-top: 25px;
margin-bottom: 15px;
color: #213547;
}
.instructions-content ul { .instructions-content ul {
padding-left: 20px; padding-left: 20px;
margin: 15px 0; margin: 15px 0;
@ -63,6 +71,7 @@
cursor: pointer; cursor: pointer;
font-weight: 500; font-weight: 500;
transition: all 0.2s; transition: all 0.2s;
font-size: 16px;
} }
.got-it-button:hover { .got-it-button:hover {
@ -77,4 +86,84 @@
border-radius: 4px; border-radius: 4px;
font-family: monospace; font-family: monospace;
margin: 0 2px; margin: 0 2px;
}
/* Mode Selection Styles */
.mode-selection {
background: #f8fafc;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
border: 1px solid #e2e8f0;
}
.mode-selection .radio-group {
display: flex;
flex-direction: column;
gap: 15px;
}
.mode-selection label {
display: flex;
flex-direction: column;
padding: 15px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: white;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.mode-selection label:hover {
border-color: #646cff;
box-shadow: 0 2px 8px rgba(100, 108, 255, 0.1);
}
.mode-selection input[type="radio"] {
position: absolute;
top: 15px;
left: 15px;
}
.mode-label {
font-weight: bold;
color: #213547;
margin-left: 25px;
font-size: 16px;
}
.mode-description {
margin: 8px 0 0 25px;
color: #64748b;
font-size: 14px;
}
.mode-selection input[type="radio"]:checked + .mode-label {
color: #646cff;
}
.mode-selection input[type="radio"]:checked ~ .mode-description {
color: #4a5568;
}
.warning-message {
background-color: #fff8e1;
border-left: 4px solid #ffb300;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
}
.warning-message h3 {
color: #e65100;
margin-top: 0;
margin-bottom: 10px;
display: flex;
align-items: center;
}
.warning-message p {
margin: 0;
color: #333;
} }

View file

@ -1,16 +1,29 @@
import React from 'react'; import React, { useState } from 'react';
import { AppMode } from '../types';
import './Instructions.css'; import './Instructions.css';
interface InstructionsProps { interface InstructionsProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
onModeSelect: (mode: AppMode) => void;
} }
export const Instructions: React.FC<InstructionsProps> = ({ visible, onClose }) => { export const Instructions: React.FC<InstructionsProps> = ({
visible,
onClose,
onModeSelect
}) => {
const [selectedMode, setSelectedMode] = useState<AppMode>('freestyle');
const handleStart = () => {
onModeSelect(selectedMode);
onClose();
};
if (!visible) return null; if (!visible) return null;
return ( return (
<div className="instructions-overlay" onClick={onClose}> <div className="instructions-overlay">
<div className="instructions-panel" onClick={e => e.stopPropagation()}> <div className="instructions-panel" onClick={e => e.stopPropagation()}>
<div className="instructions-header"> <div className="instructions-header">
<h2>Welcome to ZoomAccel !</h2> <h2>Welcome to ZoomAccel !</h2>
@ -21,6 +34,35 @@ export const Instructions: React.FC<InstructionsProps> = ({ visible, onClose })
This is a simulation that measures target acquisition performance with different zoom acceleration curves. This is a simulation that measures target acquisition performance with different zoom acceleration curves.
</p> </p>
<div className="mode-selection">
<h3>Select Mode</h3>
<div className="radio-group">
<label>
<input
type="radio"
name="initial-mode"
value="freestyle"
checked={selectedMode === 'freestyle'}
onChange={() => setSelectedMode('freestyle')}
/>
<span className="mode-label">Freestyle Mode</span>
<p className="mode-description">Test and explore different zoom acceleration settings at your own pace.</p>
</label>
<label>
<input
type="radio"
name="initial-mode"
value="study"
checked={selectedMode === 'study'}
onChange={() => setSelectedMode('study')}
/>
<span className="mode-label">Study Mode</span>
<p className="mode-description">Complete 25 targets with each preset curve (A, B, C, D) for structured data collection.</p>
</label>
</div>
</div>
<h3>Instructions</h3>
<ul> <ul>
<li> <li>
Click the green targets as quickly and accurately as possible Click the green targets as quickly and accurately as possible
@ -39,14 +81,22 @@ export const Instructions: React.FC<InstructionsProps> = ({ visible, onClose })
</li> </li>
</ul> </ul>
<div className="warning-message">
<h3> Important</h3>
<p>
Resizing your browser window will reset the target positions to match the new window dimensions.
Please avoid resizing during Study Mode to maintain consistent test conditions.
</p>
</div>
<p> <p>
Try different acceleration curves to find what works best for you! Try different acceleration curves to find what works best for you!
</p> </p>
</div> </div>
<div className="instructions-footer"> <div className="instructions-footer">
<button className="got-it-button" onClick={onClose}> <button className="got-it-button" onClick={handleStart}>
Got it! Start {selectedMode === 'freestyle' ? 'Freestyle Mode' : 'Study Mode'}
</button> </button>
</div> </div>
</div> </div>

View file

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { AccelerationSettings } from '../types'; import { AccelerationSettings, ZoomPreset, AppMode, CONSTANTS } from '../types';
import { updateSettingsCurve } from '../utils/settings'; import { updateSettingsCurve } from '../utils/settings';
import { clamp } from '../utils/math'; import { clamp } from '../utils/math';
import './Settings.css'; import './Settings.css';
@ -7,6 +7,8 @@ import './Settings.css';
interface SettingsProps { interface SettingsProps {
settings: AccelerationSettings; settings: AccelerationSettings;
onSettingsChange: (settings: AccelerationSettings) => void; onSettingsChange: (settings: AccelerationSettings) => void;
mode: AppMode;
onModeChange: (mode: AppMode) => void;
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
} }
@ -14,6 +16,8 @@ interface SettingsProps {
export const Settings: React.FC<SettingsProps> = ({ export const Settings: React.FC<SettingsProps> = ({
settings, settings,
onSettingsChange, onSettingsChange,
mode,
onModeChange,
visible, visible,
onClose, onClose,
}) => { }) => {
@ -66,6 +70,18 @@ export const Settings: React.FC<SettingsProps> = ({
} }
}; };
const handlePresetChange = (preset: ZoomPreset) => {
onSettingsChange({
...settings,
preset,
accelerationCurve: [...CONSTANTS.PRESETS[preset]]
});
};
const handleModeChange = (newMode: AppMode) => {
onModeChange(newMode);
};
if (!visible) return null; if (!visible) return null;
return ( return (
@ -75,6 +91,47 @@ export const Settings: React.FC<SettingsProps> = ({
<button onClick={onClose}>×</button> <button onClick={onClose}>×</button>
</div> </div>
<div className="mode-selection">
<h3>Mode</h3>
<div className="radio-group">
<label>
<input
type="radio"
name="mode"
value="freestyle"
checked={mode === 'freestyle'}
onChange={() => handleModeChange('freestyle')}
/>
Freestyle Mode
</label>
<label>
<input
type="radio"
name="mode"
value="study"
checked={mode === 'study'}
onChange={() => handleModeChange('study')}
/>
Study Mode (25 targets per preset)
</label>
</div>
</div>
<div className="preset-selection">
<h3>Preset Curves</h3>
<div className="preset-buttons">
{Object.keys(CONSTANTS.PRESETS).map((presetKey) => (
<button
key={presetKey}
className={`preset-button ${settings.preset === presetKey ? 'active' : ''}`}
onClick={() => handlePresetChange(presetKey as ZoomPreset)}
>
Preset {presetKey}
</button>
))}
</div>
</div>
<div className="acceleration-toggle"> <div className="acceleration-toggle">
<label> <label>
<input <input

View file

@ -9,6 +9,11 @@ export interface Stats {
misclicks: number; misclicks: number;
targetsHit: number; targetsHit: number;
times: number[]; times: number[];
zoomInTime: number; // Time spent zooming in during current trial
zoomOutTime: number; // Time spent zooming out during current trial
lastZoomTime: number; // Last timestamp of zoom event
isZooming: boolean; // Whether user is currently zooming
zoomDirection: 'in' | 'out' | null; // Current direction of zoom
} }
export interface ViewportOffset { export interface ViewportOffset {
@ -16,9 +21,13 @@ export interface ViewportOffset {
y: number; y: number;
} }
export type ZoomPreset = 'A' | 'B' | 'C' | 'D';
export type AppMode = 'freestyle' | 'study';
export interface AccelerationSettings { export interface AccelerationSettings {
enabled: boolean; enabled: boolean;
accelerationCurve: number[]; accelerationCurve: number[];
preset?: ZoomPreset;
} }
export interface Round { export interface Round {
@ -26,6 +35,9 @@ export interface Round {
misclicks: number; misclicks: number;
accelerationEnabled: boolean; accelerationEnabled: boolean;
accelerationCurve?: number[]; accelerationCurve?: number[];
preset?: ZoomPreset;
zoomInTime: number; // Time spent zooming in during this round
zoomOutTime: number; // Time spent zooming out during this round
timestamp: number; timestamp: number;
} }
@ -41,4 +53,11 @@ export const CONSTANTS = {
}, },
MOUSE_WHEEL_SAMPLES: 5, MOUSE_WHEEL_SAMPLES: 5,
NUM_CURVE_POINTS: 5, NUM_CURVE_POINTS: 5,
STUDY_TARGETS_PER_PRESET: 25,
PRESETS: {
A: [1, 1, 1, 1, 1], // Control: no acceleration
B: [1, 2, 3, 4, 5], // Linear increase
C: [1, 1.5, 2.5, 4, 6], // Slow start, fast end
D: [1, 3, 4, 3, 1], // Bell curve
} as Record<ZoomPreset, number[]>,
} as const; } as const;

View file

@ -9,12 +9,17 @@ export const calculateAverageTime = (times: number[]): number => {
export const createNewRound = ( export const createNewRound = (
time: number, time: number,
misclicks: number, misclicks: number,
settings: AccelerationSettings settings: AccelerationSettings,
zoomInTime: number,
zoomOutTime: number
): Round => ({ ): Round => ({
timeSpent: time, timeSpent: time,
misclicks, misclicks,
accelerationEnabled: settings.enabled, accelerationEnabled: settings.enabled,
accelerationCurve: settings.enabled ? [...settings.accelerationCurve] : undefined, accelerationCurve: settings.enabled ? [...settings.accelerationCurve] : undefined,
preset: settings.preset,
zoomInTime,
zoomOutTime,
timestamp: Date.now(), timestamp: Date.now(),
}); });
@ -23,4 +28,9 @@ export const createInitialStats = (): Stats => ({
misclicks: 0, misclicks: 0,
targetsHit: 0, targetsHit: 0,
times: [], times: [],
zoomInTime: 0,
zoomOutTime: 0,
lastZoomTime: 0,
isZooming: false,
zoomDirection: null,
}); });

View file

@ -27,12 +27,15 @@ export const loadState = (): StorageState | null => {
}; };
export const exportToCSV = (rounds: Round[]): void => { export const exportToCSV = (rounds: Round[]): void => {
const headers = ['Round', 'Time (ms)', 'Misclicks', 'Acceleration', 'Timestamp']; const headers = ['Round', 'Time (ms)', 'Misclicks', 'Acceleration', 'Preset', 'Zoom-In Time (ms)', 'Zoom-Out Time (ms)', 'Timestamp'];
const rows = rounds.map((round, index) => [ const rows = rounds.map((round, index) => [
index + 1, index + 1,
round.timeSpent, round.timeSpent,
round.misclicks, round.misclicks,
round.accelerationEnabled ? 'On' : 'Off', round.accelerationEnabled ? 'On' : 'Off',
round.preset || 'None',
round.zoomInTime || 0,
round.zoomOutTime || 0,
new Date(round.timestamp).toISOString() new Date(round.timestamp).toISOString()
]); ]);

View file

@ -4,9 +4,22 @@ export const generateTargets = () => {
const newTargets: Target[] = []; const newTargets: Target[] = [];
const PADDING = CONSTANTS.TARGET_MAX_RADIUS * 2; const PADDING = CONSTANTS.TARGET_MAX_RADIUS * 2;
// Calculate width segments to distribute targets evenly // Get browser window aspect ratio
const segmentWidth = (CONSTANTS.VIRTUAL_CANVAS_SIZE - PADDING * 2) / Math.sqrt(CONSTANTS.TARGET_COUNT); const windowWidth = window.innerWidth;
const numColumns = Math.ceil(Math.sqrt(CONSTANTS.TARGET_COUNT)); const windowHeight = window.innerHeight;
const aspectRatio = windowWidth / windowHeight;
// Calculate grid dimensions based on aspect ratio
const gridWidth = CONSTANTS.VIRTUAL_CANVAS_SIZE;
const gridHeight = gridWidth / aspectRatio;
// Calculate cells based on aspect ratio
const totalCells = CONSTANTS.TARGET_COUNT;
const numColumns = Math.ceil(Math.sqrt(totalCells * aspectRatio));
const numRows = Math.ceil(totalCells / numColumns);
const cellWidth = (gridWidth - PADDING * 2) / numColumns;
const cellHeight = (gridHeight - PADDING * 2) / numRows;
for (let i = 0; i < CONSTANTS.TARGET_COUNT; i++) { for (let i = 0; i < CONSTANTS.TARGET_COUNT; i++) {
let validPosition = false; let validPosition = false;
@ -21,12 +34,12 @@ export const generateTargets = () => {
Math.random() * (CONSTANTS.TARGET_MAX_RADIUS - CONSTANTS.TARGET_MIN_RADIUS); Math.random() * (CONSTANTS.TARGET_MAX_RADIUS - CONSTANTS.TARGET_MIN_RADIUS);
// Base position from grid // Base position from grid
const baseX = PADDING + column * segmentWidth; const baseX = PADDING + column * cellWidth;
const baseY = PADDING + row * segmentWidth; const baseY = PADDING + row * cellHeight;
// Add some randomness within the cell // Add some randomness within the cell
const x = baseX + Math.random() * (segmentWidth - radius * 2); const x = baseX + Math.random() * (cellWidth - radius * 2);
const y = baseY + Math.random() * (segmentWidth - radius * 2); const y = baseY + Math.random() * (cellHeight - radius * 2);
newTarget = { x, y, radius }; newTarget = { x, y, radius };
validPosition = true; validPosition = true;
@ -34,7 +47,7 @@ export const generateTargets = () => {
// Check for overlaps // Check for overlaps
for (const existing of newTargets) { for (const existing of newTargets) {
const distance = Math.hypot(existing.x - x, existing.y - y); const distance = Math.hypot(existing.x - x, existing.y - y);
if (distance < (existing.radius + radius) * 2) { if (distance < (existing.radius + radius) * 1.5) {
validPosition = false; validPosition = false;
break; break;
} }