Added the proper study mode (and freestyle mode so I can continue to test)
This commit is contained in:
parent
0ad8ca09c2
commit
b4a104a860
11 changed files with 1137 additions and 496 deletions
14
package.json
14
package.json
|
@ -14,16 +14,16 @@
|
|||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.21.0",
|
||||
"@types/react": "^19.0.10",
|
||||
"@eslint/js": "^9.23.0",
|
||||
"@types/react": "^19.0.12",
|
||||
"@types/react-dom": "^19.0.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.21.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"typescript": "~5.7.3",
|
||||
"typescript-eslint": "^8.25.0",
|
||||
"vite": "^6.1.1"
|
||||
"typescript": "~5.8.2",
|
||||
"typescript-eslint": "^8.28.0",
|
||||
"vite": "^6.2.3"
|
||||
}
|
||||
}
|
||||
|
|
835
pnpm-lock.yaml
generated
835
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
151
src/App.css
151
src/App.css
|
@ -45,9 +45,6 @@
|
|||
height: 10000px;
|
||||
transform-origin: 0 0;
|
||||
background-color: white;
|
||||
background-image: linear-gradient(#ddd 1px, transparent 1px),
|
||||
linear-gradient(90deg, #ddd 1px, transparent 1px);
|
||||
background-size: 100px 100px;
|
||||
}
|
||||
|
||||
.target {
|
||||
|
@ -127,3 +124,151 @@ body,
|
|||
background: #fff5f5;
|
||||
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;
|
||||
}
|
||||
}
|
358
src/App.tsx
358
src/App.tsx
|
@ -10,6 +10,8 @@ import {
|
|||
AccelerationSettings,
|
||||
Round,
|
||||
CONSTANTS,
|
||||
ZoomPreset,
|
||||
AppMode,
|
||||
} from './types';
|
||||
import { generateTargets, getRandomTarget } from './utils/target';
|
||||
import {
|
||||
|
@ -35,6 +37,13 @@ function App() {
|
|||
const [zoomLevel, setZoomLevel] = useState<number>(CONSTANTS.ZOOM_LEVELS.default);
|
||||
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
|
||||
const [settings, setSettings] = useState<AccelerationSettings>(() => {
|
||||
const savedState = loadState();
|
||||
|
@ -68,6 +77,9 @@ function App() {
|
|||
const touchStartDistance = useRef<number>(0);
|
||||
const wasReset = useRef(false);
|
||||
|
||||
// Add a new state for the resize warning popup
|
||||
const [showResizeWarning, setShowResizeWarning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedState = loadState();
|
||||
if (savedState?.stats) {
|
||||
|
@ -96,9 +108,51 @@ function App() {
|
|||
const initialZoom = 0.2;
|
||||
setZoomLevel(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(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
@ -115,6 +169,36 @@ function App() {
|
|||
const worldY = viewportOffset.y + cursorY / zoomLevel;
|
||||
|
||||
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)) {
|
||||
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 });
|
||||
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]);
|
||||
|
||||
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
|
@ -210,7 +315,7 @@ function App() {
|
|||
};
|
||||
|
||||
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 (isDragging.current || Date.now() - dragStartTime.current > 200) {
|
||||
|
@ -228,14 +333,41 @@ function App() {
|
|||
|
||||
if (distanceToTarget <= target.radius) {
|
||||
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({
|
||||
startTime: Date.now(),
|
||||
misclicks: 0,
|
||||
targetsHit: stats.targetsHit + 1,
|
||||
times: [...stats.times, timeElapsed],
|
||||
zoomInTime: 0,
|
||||
zoomOutTime: 0,
|
||||
lastZoomTime: 0,
|
||||
isZooming: false,
|
||||
zoomDirection: null
|
||||
});
|
||||
|
||||
// Set next target
|
||||
setCurrentTarget(getRandomTarget(currentTarget, CONSTANTS.TARGET_COUNT));
|
||||
|
||||
// If in study mode, increment target count
|
||||
if (mode === 'study') {
|
||||
setTargetCount(prev => prev + 1);
|
||||
}
|
||||
} else {
|
||||
setStats(previous => ({
|
||||
...previous,
|
||||
|
@ -249,80 +381,106 @@ function App() {
|
|||
setRounds([]);
|
||||
setStats(createInitialStats());
|
||||
setSettings(createDefaultSettings());
|
||||
initializeGame();
|
||||
wasReset.current = true;
|
||||
initializeGame();
|
||||
|
||||
// Close any open panels
|
||||
setShowSettings(false);
|
||||
setShowRounds(false);
|
||||
|
||||
// Show the initial instructions screen
|
||||
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 (
|
||||
<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">
|
||||
<span>Targets: {stats.targetsHit}</span> •
|
||||
<span>Misclicks: {stats.misclicks}</span> •
|
||||
<span>Avg Time: {calculateAverageTime(stats.times)} ms</span> •
|
||||
<span>Zoom: {zoomLevel.toFixed(2)}x</span>
|
||||
<span>
|
||||
<strong>Targets hit:</strong> {stats.targetsHit}
|
||||
</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
|
||||
ref={containerRef}
|
||||
className="container"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onClick={handleClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onClick={handleClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
<div
|
||||
className="content"
|
||||
|
@ -345,6 +503,82 @@ function App() {
|
|||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,10 +15,12 @@
|
|||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 30px;
|
||||
max-width: 500px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||
position: relative;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.instructions-header {
|
||||
|
@ -39,6 +41,12 @@
|
|||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.instructions-content h3 {
|
||||
margin-top: 25px;
|
||||
margin-bottom: 15px;
|
||||
color: #213547;
|
||||
}
|
||||
|
||||
.instructions-content ul {
|
||||
padding-left: 20px;
|
||||
margin: 15px 0;
|
||||
|
@ -63,6 +71,7 @@
|
|||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.got-it-button:hover {
|
||||
|
@ -78,3 +87,83 @@
|
|||
font-family: monospace;
|
||||
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;
|
||||
}
|
|
@ -1,16 +1,29 @@
|
|||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { AppMode } from '../types';
|
||||
import './Instructions.css';
|
||||
|
||||
interface InstructionsProps {
|
||||
visible: boolean;
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="instructions-overlay" onClick={onClose}>
|
||||
<div className="instructions-overlay">
|
||||
<div className="instructions-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="instructions-header">
|
||||
<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.
|
||||
</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>
|
||||
<li>
|
||||
Click the green targets as quickly and accurately as possible
|
||||
|
@ -39,14 +81,22 @@ export const Instructions: React.FC<InstructionsProps> = ({ visible, onClose })
|
|||
</li>
|
||||
</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>
|
||||
Try different acceleration curves to find what works best for you!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="instructions-footer">
|
||||
<button className="got-it-button" onClick={onClose}>
|
||||
Got it!
|
||||
<button className="got-it-button" onClick={handleStart}>
|
||||
Start {selectedMode === 'freestyle' ? 'Freestyle Mode' : 'Study Mode'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState } from 'react';
|
||||
import { AccelerationSettings } from '../types';
|
||||
import { AccelerationSettings, ZoomPreset, AppMode, CONSTANTS } from '../types';
|
||||
import { updateSettingsCurve } from '../utils/settings';
|
||||
import { clamp } from '../utils/math';
|
||||
import './Settings.css';
|
||||
|
@ -7,6 +7,8 @@ import './Settings.css';
|
|||
interface SettingsProps {
|
||||
settings: AccelerationSettings;
|
||||
onSettingsChange: (settings: AccelerationSettings) => void;
|
||||
mode: AppMode;
|
||||
onModeChange: (mode: AppMode) => void;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
@ -14,6 +16,8 @@ interface SettingsProps {
|
|||
export const Settings: React.FC<SettingsProps> = ({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
mode,
|
||||
onModeChange,
|
||||
visible,
|
||||
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;
|
||||
|
||||
return (
|
||||
|
@ -75,6 +91,47 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||
<button onClick={onClose}>×</button>
|
||||
</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">
|
||||
<label>
|
||||
<input
|
||||
|
|
19
src/types.ts
19
src/types.ts
|
@ -9,6 +9,11 @@ export interface Stats {
|
|||
misclicks: number;
|
||||
targetsHit: 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 {
|
||||
|
@ -16,9 +21,13 @@ export interface ViewportOffset {
|
|||
y: number;
|
||||
}
|
||||
|
||||
export type ZoomPreset = 'A' | 'B' | 'C' | 'D';
|
||||
export type AppMode = 'freestyle' | 'study';
|
||||
|
||||
export interface AccelerationSettings {
|
||||
enabled: boolean;
|
||||
accelerationCurve: number[];
|
||||
preset?: ZoomPreset;
|
||||
}
|
||||
|
||||
export interface Round {
|
||||
|
@ -26,6 +35,9 @@ export interface Round {
|
|||
misclicks: number;
|
||||
accelerationEnabled: boolean;
|
||||
accelerationCurve?: number[];
|
||||
preset?: ZoomPreset;
|
||||
zoomInTime: number; // Time spent zooming in during this round
|
||||
zoomOutTime: number; // Time spent zooming out during this round
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
|
@ -41,4 +53,11 @@ export const CONSTANTS = {
|
|||
},
|
||||
MOUSE_WHEEL_SAMPLES: 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;
|
||||
|
|
|
@ -9,12 +9,17 @@ export const calculateAverageTime = (times: number[]): number => {
|
|||
export const createNewRound = (
|
||||
time: number,
|
||||
misclicks: number,
|
||||
settings: AccelerationSettings
|
||||
settings: AccelerationSettings,
|
||||
zoomInTime: number,
|
||||
zoomOutTime: number
|
||||
): Round => ({
|
||||
timeSpent: time,
|
||||
misclicks,
|
||||
accelerationEnabled: settings.enabled,
|
||||
accelerationCurve: settings.enabled ? [...settings.accelerationCurve] : undefined,
|
||||
preset: settings.preset,
|
||||
zoomInTime,
|
||||
zoomOutTime,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
|
@ -23,4 +28,9 @@ export const createInitialStats = (): Stats => ({
|
|||
misclicks: 0,
|
||||
targetsHit: 0,
|
||||
times: [],
|
||||
zoomInTime: 0,
|
||||
zoomOutTime: 0,
|
||||
lastZoomTime: 0,
|
||||
isZooming: false,
|
||||
zoomDirection: null,
|
||||
});
|
||||
|
|
|
@ -27,12 +27,15 @@ export const loadState = (): StorageState | null => {
|
|||
};
|
||||
|
||||
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) => [
|
||||
index + 1,
|
||||
round.timeSpent,
|
||||
round.misclicks,
|
||||
round.accelerationEnabled ? 'On' : 'Off',
|
||||
round.preset || 'None',
|
||||
round.zoomInTime || 0,
|
||||
round.zoomOutTime || 0,
|
||||
new Date(round.timestamp).toISOString()
|
||||
]);
|
||||
|
||||
|
|
|
@ -4,9 +4,22 @@ export const generateTargets = () => {
|
|||
const newTargets: Target[] = [];
|
||||
const PADDING = CONSTANTS.TARGET_MAX_RADIUS * 2;
|
||||
|
||||
// Calculate width segments to distribute targets evenly
|
||||
const segmentWidth = (CONSTANTS.VIRTUAL_CANVAS_SIZE - PADDING * 2) / Math.sqrt(CONSTANTS.TARGET_COUNT);
|
||||
const numColumns = Math.ceil(Math.sqrt(CONSTANTS.TARGET_COUNT));
|
||||
// Get browser window aspect ratio
|
||||
const windowWidth = window.innerWidth;
|
||||
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++) {
|
||||
let validPosition = false;
|
||||
|
@ -21,12 +34,12 @@ export const generateTargets = () => {
|
|||
Math.random() * (CONSTANTS.TARGET_MAX_RADIUS - CONSTANTS.TARGET_MIN_RADIUS);
|
||||
|
||||
// Base position from grid
|
||||
const baseX = PADDING + column * segmentWidth;
|
||||
const baseY = PADDING + row * segmentWidth;
|
||||
const baseX = PADDING + column * cellWidth;
|
||||
const baseY = PADDING + row * cellHeight;
|
||||
|
||||
// Add some randomness within the cell
|
||||
const x = baseX + Math.random() * (segmentWidth - radius * 2);
|
||||
const y = baseY + Math.random() * (segmentWidth - radius * 2);
|
||||
const x = baseX + Math.random() * (cellWidth - radius * 2);
|
||||
const y = baseY + Math.random() * (cellHeight - radius * 2);
|
||||
|
||||
newTarget = { x, y, radius };
|
||||
validPosition = true;
|
||||
|
@ -34,7 +47,7 @@ export const generateTargets = () => {
|
|||
// Check for overlaps
|
||||
for (const existing of newTargets) {
|
||||
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;
|
||||
break;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue