1
0
Fork 0

Huge re-factor...I have too much time on my hands...

This commit is contained in:
Atridad Lahiji 2025-02-13 23:54:04 -06:00
parent 398b2df042
commit 94abfe82a9
Signed by: atridad
SSH key fingerprint: SHA256:LGomp8Opq0jz+7kbwNcdfTcuaLRb5Nh0k5AchDDb438
19 changed files with 674 additions and 236 deletions

View file

@ -14,16 +14,16 @@
"react-dom": "^19.0.0"
},
"devDependencies": {
"@eslint/js": "^9.19.0",
"@eslint/js": "^9.20.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.19.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.18",
"globals": "^15.14.0",
"typescript": "~5.7.2",
"typescript-eslint": "^8.22.0",
"eslint": "^9.20.1",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^15.15.0",
"typescript": "~5.7.3",
"typescript-eslint": "^8.24.0",
"vite": "^6.1.0"
}
}

14
pnpm-lock.yaml generated
View file

@ -16,7 +16,7 @@ importers:
version: 19.0.0(react@19.0.0)
devDependencies:
'@eslint/js':
specifier: ^9.19.0
specifier: ^9.20.0
version: 9.20.0
'@types/react':
specifier: ^19.0.8
@ -28,22 +28,22 @@ importers:
specifier: ^4.3.4
version: 4.3.4(vite@6.1.0)
eslint:
specifier: ^9.19.0
specifier: ^9.20.1
version: 9.20.1
eslint-plugin-react-hooks:
specifier: ^5.0.0
specifier: ^5.1.0
version: 5.1.0(eslint@9.20.1)
eslint-plugin-react-refresh:
specifier: ^0.4.18
specifier: ^0.4.19
version: 0.4.19(eslint@9.20.1)
globals:
specifier: ^15.14.0
specifier: ^15.15.0
version: 15.15.0
typescript:
specifier: ~5.7.2
specifier: ~5.7.3
version: 5.7.3
typescript-eslint:
specifier: ^8.22.0
specifier: ^8.24.0
version: 8.24.0(eslint@9.20.1)(typescript@5.7.3)
vite:
specifier: ^6.1.0

View file

@ -41,7 +41,7 @@
.content {
position: absolute;
width: 10000px;
/* Match VIRTUAL_SIZE */
/* Match VIRTUAL_CANVAS_SIZE */
height: 10000px;
transform-origin: 0 0;
background-color: white;

View file

@ -1,7 +1,8 @@
import React, { useState, useRef, useEffect } from 'react';
import { Settings } from './components/Settings';
import { RoundsHistory } from './components/RoundsHistory';
import { saveState, loadState, exportToCSV } from './utils';
import { Instructions } from './components/Instructions';
import { saveState, loadState, exportToCSV } from './utils/storage';
import {
Target,
Stats,
@ -10,27 +11,35 @@ import {
Round,
CONSTANTS,
} from './types';
import { generateTargets, getRandomTarget } from './utils/target';
import {
calculateInitialViewport,
clampZoom,
calculateTouchDistance,
calculateZoomFactor
} from './utils/interaction';
import {
createNewRound,
createInitialStats,
calculateAverageTime
} from './utils/stats';
import { calculateAccelerationFactor, processWheelDelta, updateWheelSamples } from './utils/acceleration';
import { createDefaultSettings } from './utils/settings';
import { clamp } from './utils/math';
import './App.css';
function App() {
// Core state
const [targets, setTargets] = useState<Target[]>([]);
const [currentTarget, setCurrentTarget] = useState<number | null>(null);
const [zoom, setZoom] = useState<number>(CONSTANTS.ZOOM_LEVELS.default);
const [stats, setStats] = useState<Stats>({
startTime: Date.now(),
misclicks: 0,
targetsHit: 0,
times: [],
});
const [zoomLevel, setZoomLevel] = useState<number>(CONSTANTS.ZOOM_LEVELS.default);
const [stats, setStats] = useState<Stats>(createInitialStats());
// Load settings from localStorage or use defaults
const [settings, setSettings] = useState<AccelerationSettings>(() => {
const savedState = loadState();
return savedState?.settings ?? {
enabled: true,
curve: Array(CONSTANTS.NUM_CURVE_POINTS).fill(1),
};
const defaultSettings = createDefaultSettings();
return savedState?.settings ?? defaultSettings;
});
const [rounds, setRounds] = useState<Round[]>(() => {
@ -41,21 +50,30 @@ function App() {
// UI state
const [showSettings, setShowSettings] = useState(false);
const [showRounds, setShowRounds] = useState(false);
const [showInstructions, setShowInstructions] = useState(false);
const [viewportOffset, setViewportOffset] = useState<ViewportOffset>({
x: 0,
y: 0,
});
// Add state to track drag operations
const isDragging = useRef(false);
const dragStartTime = useRef(0);
const dragStartPos = useRef({ x: 0, y: 0 });
const DRAG_THRESHOLD = 5; // pixels
// Refs for handling zoom behavior
const containerRef = useRef<HTMLDivElement>(null);
const wheelDeltaSamples = useRef<number[]>([]);
const zoomSpeedSamples = useRef<number[]>([]);
const touchStartDistance = useRef<number>(0);
const wasReset = useRef(false);
useEffect(() => {
const savedState = loadState();
if (savedState?.stats) {
setStats(savedState.stats);
}
setShowInstructions(!savedState?.rounds || savedState.rounds.length === 0);
}, []);
useEffect(() => {
@ -67,70 +85,64 @@ function App() {
}, [rounds, stats, settings]);
useEffect(() => {
generateTargets();
initializeGame();
}, []);
const initializeGame = () => {
const newTargets = generateTargets();
setTargets(newTargets);
setCurrentTarget(getRandomTarget(null, CONSTANTS.TARGET_COUNT));
const initialZoom = 0.2;
setZoomLevel(initialZoom);
setViewportOffset(calculateInitialViewport(initialZoom));
setStats(previous => ({ ...previous, startTime: Date.now() }));
};
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleWheelEvent = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const handleWheelEvent = (event: WheelEvent) => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
const rect = container.getBoundingClientRect();
const cursorX = e.clientX - rect.left;
const cursorY = e.clientY - rect.top;
const containerBounds = container.getBoundingClientRect();
const cursorX = event.clientX - containerBounds.left;
const cursorY = event.clientY - containerBounds.top;
const virtualX = viewportOffset.x + cursorX / zoom;
const virtualY = viewportOffset.y + cursorY / zoom;
const worldX = viewportOffset.x + cursorX / zoomLevel;
const worldY = viewportOffset.y + cursorY / zoomLevel;
// Prevent crazy fast scrolling
const deltaY = Math.max(-100, Math.min(100, -e.deltaY));
const scrollAmount = processWheelDelta(-event.deltaY);
if (isFinite(deltaY)) {
wheelDeltaSamples.current.push(Math.abs(deltaY));
if (wheelDeltaSamples.current.length > CONSTANTS.WHEEL_SAMPLES) {
wheelDeltaSamples.current.shift();
}
if (isFinite(scrollAmount)) {
zoomSpeedSamples.current = updateWheelSamples(zoomSpeedSamples.current, scrollAmount);
}
let zoomDelta = deltaY * 0.001;
let zoomDelta = scrollAmount * 0.001;
// Apply acceleration curve if enabled
if (settings.enabled && wheelDeltaSamples.current.length > 0) {
const averageSpeed = wheelDeltaSamples.current.reduce((a, b) => a + b, 0) /
wheelDeltaSamples.current.length;
const normalizedSpeed = Math.max(0, Math.min(2, averageSpeed / 100));
const curveIndex = normalizedSpeed * (settings.curve.length - 1);
const lowerIndex = Math.floor(curveIndex);
const upperIndex = Math.min(settings.curve.length - 1, Math.ceil(curveIndex));
const t = curveIndex - lowerIndex;
const accelerationFactor =
settings.curve[lowerIndex] * (1 - t) +
settings.curve[upperIndex] * t;
if (settings.enabled && zoomSpeedSamples.current.length > 0) {
const accelerationFactor = calculateAccelerationFactor(
zoomSpeedSamples.current,
settings.accelerationCurve,
settings.enabled
);
if (isFinite(accelerationFactor)) {
zoomDelta *= accelerationFactor;
}
}
zoomDelta = Math.max(-0.5, Math.min(0.5, zoomDelta));
zoomDelta = clamp(zoomDelta, -0.5, 0.5);
const newZoom = clampZoom(zoomLevel * (1 + zoomDelta));
const newZoom = Math.max(
CONSTANTS.ZOOM_LEVELS.min,
Math.min(CONSTANTS.ZOOM_LEVELS.max, zoom * (1 + zoomDelta))
);
// Sanity check before updating state
if (isFinite(newZoom) && newZoom > 0) {
const newOffsetX = virtualX - cursorX / newZoom;
const newOffsetY = virtualY - cursorY / newZoom;
const newOffsetX = worldX - cursorX / newZoom;
const newOffsetY = worldY - cursorY / newZoom;
if (isFinite(newOffsetX) && isFinite(newOffsetY)) {
setZoom(newZoom);
setZoomLevel(newZoom);
setViewportOffset({ x: newOffsetX, y: newOffsetY });
}
}
@ -138,87 +150,25 @@ function App() {
};
container.addEventListener('wheel', handleWheelEvent, { passive: false });
return () => container.removeEventListener('wheel', handleWheelEvent);
}, [zoomLevel, viewportOffset, settings]);
return () => {
container.removeEventListener('wheel', handleWheelEvent);
};
}, [zoom, viewportOffset, settings]);
const generateTargets = () => {
const newTargets: Target[] = [];
const PADDING = CONSTANTS.MAX_RADIUS * 2;
const usableArea = {
start: PADDING,
end: CONSTANTS.VIRTUAL_SIZE - PADDING
};
for (let i = 0; i < CONSTANTS.NUM_TARGETS; i++) {
let validPosition = false;
let newTarget: Target;
while (!validPosition) {
const radius = CONSTANTS.MIN_RADIUS +
Math.random() * (CONSTANTS.MAX_RADIUS - CONSTANTS.MIN_RADIUS);
const x = usableArea.start + Math.random() * (usableArea.end - usableArea.start);
const y = usableArea.start + Math.random() * (usableArea.end - usableArea.start);
newTarget = { x, y, radius };
validPosition = true;
for (const existing of newTargets) {
const distance = Math.hypot(existing.x - x, existing.y - y);
if (distance < (existing.radius + radius) * 2) {
validPosition = false;
break;
}
}
}
newTargets.push(newTarget!);
}
setTargets(newTargets);
setCurrentTarget(Math.floor(Math.random() * CONSTANTS.NUM_TARGETS));
const initialZoom = 0.2;
setZoom(initialZoom);
setViewportOffset({
x: (CONSTANTS.VIRTUAL_SIZE - window.innerWidth / initialZoom) / 2,
y: (CONSTANTS.VIRTUAL_SIZE - window.innerHeight / initialZoom) / 2,
});
setStats((prev) => ({ ...prev, startTime: Date.now() }));
};
const handleTouchStart = (e: React.TouchEvent) => {
if (e.touches.length === 2) {
const touch1 = e.touches[0];
const touch2 = e.touches[1];
touchStartDistance.current = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
);
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
if (event.touches.length === 2) {
const touch1 = event.touches[0];
const touch2 = event.touches[1];
touchStartDistance.current = calculateTouchDistance(touch1, touch2);
}
};
const handleTouchMove = (e: React.TouchEvent) => {
if (e.touches.length === 2) {
e.preventDefault();
const touch1 = e.touches[0];
const touch2 = e.touches[1];
const currentDistance = Math.hypot(
touch2.clientX - touch1.clientX,
touch2.clientY - touch1.clientY
);
const zoomFactor = currentDistance / touchStartDistance.current;
setZoom((prev) =>
Math.max(
CONSTANTS.ZOOM_LEVELS.min,
Math.min(CONSTANTS.ZOOM_LEVELS.max, prev * zoomFactor)
)
);
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
if (event.touches.length === 2) {
event.preventDefault();
const touch1 = event.touches[0];
const touch2 = event.touches[1];
const currentDistance = calculateTouchDistance(touch1, touch2);
const zoomFactor = calculateZoomFactor(currentDistance, touchStartDistance.current);
setZoomLevel(previous => clampZoom(previous * zoomFactor));
}
};
@ -226,78 +176,85 @@ function App() {
touchStartDistance.current = 0;
};
const handleClick = (e: React.MouseEvent) => {
if (!containerRef.current || currentTarget === null) return;
const handleMouseMove = (event: React.MouseEvent) => {
if (event.buttons === 1) {
// Calculate total drag distance
const dx = event.clientX - dragStartPos.current.x;
const dy = event.clientY - dragStartPos.current.y;
const dragDistance = Math.hypot(dx, dy);
const rect = containerRef.current.getBoundingClientRect();
const x = (e.clientX - rect.left) / zoom + viewportOffset.x;
const y = (e.clientY - rect.top) / zoom + viewportOffset.y;
// Only set dragging if we've moved past the threshold
if (dragDistance > DRAG_THRESHOLD) {
isDragging.current = true;
}
const target = targets[currentTarget];
if (!target) return;
const distance = Math.hypot(target.x - x, target.y - y);
if (distance <= target.radius) {
const time = Date.now() - stats.startTime;
// Log the round with acceleration curve if enabled
setRounds(prev => [...prev, {
timeSpent: time,
misclicks: stats.misclicks,
accelerationEnabled: settings.enabled,
accelerationCurve: settings.enabled ? [...settings.curve] : undefined,
timestamp: Date.now(),
}]);
// Reset stats for next round
setStats({
startTime: Date.now(),
misclicks: 0,
targetsHit: stats.targetsHit + 1,
times: [...stats.times, time],
});
// Select new target
let newTarget: number;
do {
newTarget = Math.floor(Math.random() * CONSTANTS.NUM_TARGETS);
} while (newTarget === currentTarget);
setCurrentTarget(newTarget);
} else {
setStats((prev) => ({
...prev,
misclicks: prev.misclicks + 1,
setViewportOffset(previous => ({
x: previous.x - event.movementX / zoomLevel,
y: previous.y - event.movementY / zoomLevel,
}));
}
};
const handleMouseMove = (e: React.MouseEvent) => {
if (e.buttons === 1) {
setViewportOffset((prev) => ({
x: prev.x - e.movementX / zoom,
y: prev.y - e.movementY / zoom,
const handleMouseDown = (event: React.MouseEvent) => {
dragStartTime.current = Date.now();
dragStartPos.current = { x: event.clientX, y: event.clientY };
isDragging.current = false;
};
const handleMouseUp = () => {
// Reset drag state after a short delay to allow click event to check it
setTimeout(() => {
isDragging.current = false;
dragStartPos.current = { x: 0, y: 0 };
}, 0);
};
const handleClick = (event: React.MouseEvent) => {
if (!containerRef.current || currentTarget === null) 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) {
return;
}
const containerBounds = containerRef.current.getBoundingClientRect();
const worldX = (event.clientX - containerBounds.left) / zoomLevel + viewportOffset.x;
const worldY = (event.clientY - containerBounds.top) / zoomLevel + viewportOffset.y;
const target = targets[currentTarget];
if (!target) return;
const distanceToTarget = Math.hypot(target.x - worldX, target.y - worldY);
if (distanceToTarget <= target.radius) {
const timeElapsed = Date.now() - stats.startTime;
setRounds(previous => [...previous, createNewRound(timeElapsed, stats.misclicks, settings)]);
setStats({
startTime: Date.now(),
misclicks: 0,
targetsHit: stats.targetsHit + 1,
times: [...stats.times, timeElapsed],
});
setCurrentTarget(getRandomTarget(currentTarget, CONSTANTS.TARGET_COUNT));
} else {
setStats(previous => ({
...previous,
misclicks: previous.misclicks + 1,
}));
}
};
const handleReset = () => {
if (window.confirm('Are you sure you want to reset all trials and history? This cannot be undone.')) {
if (window.confirm('Are you sure you want to reset all trials, history, and settings? This cannot be undone.')) {
setRounds([]);
setStats({
startTime: Date.now(),
misclicks: 0,
targetsHit: 0,
times: [],
});
generateTargets();
setStats(createInitialStats());
setSettings(createDefaultSettings());
initializeGame();
wasReset.current = true;
setShowInstructions(true);
}
};
const handleExport = () => {
exportToCSV(rounds);
};
return (
<div className="app">
<div className="controls">
@ -317,7 +274,7 @@ function App() {
<button
className="export-button"
onClick={handleExport}
onClick={() => exportToCSV(rounds)}
disabled={rounds.length === 0}
>
📥 Export CSV
@ -344,13 +301,16 @@ function App() {
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: {stats.times.length
? Math.round(stats.times.reduce((a, b) => a + b) / stats.times.length)
: 0} ms</span>
<span>Zoom: {zoom.toFixed(2)}x</span>
<span>Avg Time: {calculateAverageTime(stats.times)} ms</span>
<span>Zoom: {zoomLevel.toFixed(2)}x</span>
</div>
<div
@ -361,19 +321,20 @@ function App() {
onTouchEnd={handleTouchEnd}
onClick={handleClick}
onMouseMove={handleMouseMove}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
>
<div
className="content"
style={{
transform: `translate(${-viewportOffset.x * zoom}px, ${-viewportOffset.y * zoom
}px) scale(${zoom})`,
transform: `translate(${-viewportOffset.x * zoomLevel}px, ${-viewportOffset.y * zoomLevel}px) scale(${zoomLevel})`,
transformOrigin: '0 0',
}}
>
{targets.map((target, i) => (
{targets.map((target, index) => (
<div
key={i}
className={`target ${i === currentTarget ? 'active' : ''}`}
key={index}
className={`target ${index === currentTarget ? 'active' : ''}`}
style={{
left: `${target.x}px`,
top: `${target.y}px`,

View file

@ -0,0 +1,80 @@
.instructions-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 2000;
}
.instructions-panel {
background: white;
border-radius: 12px;
padding: 30px;
max-width: 500px;
width: 90%;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
position: relative;
}
.instructions-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.instructions-header h2 {
margin: 0;
font-size: 1.5em;
color: #213547;
}
.instructions-content {
color: #4a5568;
line-height: 1.6;
}
.instructions-content ul {
padding-left: 20px;
margin: 15px 0;
}
.instructions-content li {
margin: 8px 0;
}
.instructions-footer {
margin-top: 25px;
display: flex;
justify-content: flex-end;
}
.got-it-button {
background: #646cff;
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s;
}
.got-it-button:hover {
background: #535bf2;
transform: translateY(-1px);
}
.keyboard-shortcut {
display: inline-block;
background: #f1f5f9;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
margin: 0 2px;
}

View file

@ -0,0 +1,55 @@
import React from 'react';
import './Instructions.css';
interface InstructionsProps {
visible: boolean;
onClose: () => void;
}
export const Instructions: React.FC<InstructionsProps> = ({ visible, onClose }) => {
if (!visible) return null;
return (
<div className="instructions-overlay" onClick={onClose}>
<div className="instructions-panel" onClick={e => e.stopPropagation()}>
<div className="instructions-header">
<h2>Welcome to Zoom Acceleration!</h2>
</div>
<div className="instructions-content">
<p>
This is a target acquisition test that measures your performance with different zoom acceleration curves.
</p>
<ul>
<li>
Click the green targets as quickly and accurately as possible
</li>
<li>
Use <span className="keyboard-shortcut">Ctrl + Scroll</span> or <span className="keyboard-shortcut"> + Scroll</span> to zoom
</li>
<li>
Click and drag to pan the view
</li>
<li>
Toggle zoom acceleration and customize the curve in Settings <span className="keyboard-shortcut"></span>
</li>
<li>
View your performance history and export results using the History panel <span className="keyboard-shortcut">📊</span>
</li>
</ul>
<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>
</div>
</div>
</div>
);
};

View file

@ -57,11 +57,27 @@
flex: 1;
}
.slider-row span {
width: 40px;
text-align: right;
.slider-row .value-input {
width: 50px;
text-align: center;
font-size: 14px;
color: #666;
color: #213547;
padding: 4px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
}
.slider-row .value-input:focus {
outline: none;
border-color: #646cff;
box-shadow: 0 0 0 2px rgba(100, 108, 255, 0.2);
}
.slider-row .value-input:disabled {
background: #f5f5f5;
color: #999;
cursor: not-allowed;
}
.acceleration-toggle {

View file

@ -1,5 +1,7 @@
import React from 'react';
import React, { useState } from 'react';
import { AccelerationSettings } from '../types';
import { updateSettingsCurve } from '../utils/settings';
import { clamp } from '../utils/math';
import './Settings.css';
interface SettingsProps {
@ -16,11 +18,52 @@ export const Settings: React.FC<SettingsProps> = ({
onClose,
}) => {
const numSliders = 5;
const [editingValue, setEditingValue] = useState<string[]>(
new Array(numSliders).fill('')
);
const handleSliderChange = (index: number, value: number) => {
const newCurve = [...settings.curve];
newCurve[index] = value;
onSettingsChange({ ...settings, curve: newCurve });
const newSettings = updateSettingsCurve(settings, index, value);
onSettingsChange(newSettings);
setEditingValue(prev => {
const next = [...prev];
next[index] = '';
return next;
});
};
const handleInputChange = (index: number, value: string) => {
setEditingValue(prev => {
const next = [...prev];
next[index] = value;
return next;
});
};
const handleInputBlur = (index: number) => {
const value = parseFloat(editingValue[index]);
if (!isNaN(value)) {
const clampedValue = clamp(value, 1, 10);
handleSliderChange(index, clampedValue);
}
setEditingValue(prev => {
const next = [...prev];
next[index] = '';
return next;
});
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, index: number) => {
if (e.key === 'Enter') {
e.currentTarget.blur();
} else if (e.key === 'Escape') {
setEditingValue(prev => {
const next = [...prev];
next[index] = '';
return next;
});
e.currentTarget.blur();
}
};
if (!visible) return null;
@ -55,11 +98,20 @@ export const Settings: React.FC<SettingsProps> = ({
min="1"
max="10"
step="0.1"
value={settings.curve[i]}
value={settings.accelerationCurve?.[i] ?? 1}
onChange={(e) => handleSliderChange(i, parseFloat(e.target.value))}
disabled={!settings.enabled}
/>
<span>{settings.curve[i].toFixed(1)}x</span>
<input
type="text"
className="value-input"
value={editingValue[i] || (settings.accelerationCurve?.[i] ?? 1).toFixed(1)}
onChange={(e) => handleInputChange(i, e.target.value)}
onBlur={() => handleInputBlur(i)}
onKeyDown={(e) => handleInputKeyDown(e, i)}
disabled={!settings.enabled}
placeholder={(settings.accelerationCurve?.[i] ?? 1).toFixed(1)}
/>
</div>
))}
</div>

View file

@ -18,28 +18,27 @@ export interface ViewportOffset {
export interface AccelerationSettings {
enabled: boolean;
curve: number[];
accelerationCurve: number[];
}
export interface Round {
timeSpent: number;
misclicks: number;
accelerationEnabled: boolean;
accelerationCurve?: number[]; // Only present when acceleration is enabled
accelerationCurve?: number[];
timestamp: number;
}
// Game constants
export const CONSTANTS = {
NUM_TARGETS: 50,
MIN_RADIUS: 20,
MAX_RADIUS: 60,
VIRTUAL_SIZE: 10000, // Size of the virtual canvas
TARGET_COUNT: 200,
TARGET_MIN_RADIUS: 100,
TARGET_MAX_RADIUS: 800,
VIRTUAL_CANVAS_SIZE: 50000,
ZOOM_LEVELS: {
min: 0.1,
min: 0.01,
max: 10,
default: 1,
default: 0.2,
},
WHEEL_SAMPLES: 5, // Number of samples to average for smooth zooming
NUM_CURVE_POINTS: 5, // Points in the acceleration curve
MOUSE_WHEEL_SAMPLES: 5,
NUM_CURVE_POINTS: 5,
} as const;

33
src/utils/acceleration.ts Normal file
View file

@ -0,0 +1,33 @@
import { clamp } from './math';
import { interpolateCurveValue } from './settings';
import { CONSTANTS } from '../types';
export const calculateAccelerationFactor = (
wheelSamples: number[],
accelerationCurve: number[],
enabled: boolean
): number => {
if (!enabled || !wheelSamples || wheelSamples.length === 0 || !accelerationCurve) return 1;
// Calculate average speed in one pass
const averageSpeed = wheelSamples.reduce((sum, val) => sum + Math.abs(val), 0) / wheelSamples.length;
const normalizedSpeed = clamp(averageSpeed / 100, 0, 2);
return interpolateCurveValue(accelerationCurve, normalizedSpeed);
};
export const processWheelDelta = (
rawDelta: number,
maxDelta = 100
): number => {
return clamp(rawDelta, -maxDelta, maxDelta);
};
export const updateWheelSamples = (
samples: number[],
newValue: number
): number[] => {
const newSamples = samples.length >= CONSTANTS.MOUSE_WHEEL_SAMPLES
? [...samples.slice(1), Math.abs(newValue)]
: [...samples, Math.abs(newValue)];
return newSamples;
};

24
src/utils/interaction.ts Normal file
View file

@ -0,0 +1,24 @@
import { ViewportOffset, CONSTANTS } from '../types';
import { clamp, calculateDistance } from './math';
// Zoom-related functions
export const calculateZoomDelta = (
wheelDelta: number,
accelerationFactor: number,
baseMultiplier = 0.001
): number => clamp(wheelDelta * baseMultiplier * accelerationFactor, -0.5, 0.5);
export const calculateInitialViewport = (initialZoom: number): ViewportOffset => ({
x: (CONSTANTS.VIRTUAL_CANVAS_SIZE - window.innerWidth / initialZoom) / 2,
y: (CONSTANTS.VIRTUAL_CANVAS_SIZE - window.innerHeight / initialZoom) / 2,
});
export const clampZoom = (zoom: number): number =>
clamp(zoom, CONSTANTS.ZOOM_LEVELS.min, CONSTANTS.ZOOM_LEVELS.max);
// Touch-related functions
export const calculateTouchDistance = (touch1: React.Touch | Touch, touch2: React.Touch | Touch): number =>
calculateDistance(touch1.clientX, touch1.clientY, touch2.clientX, touch2.clientY);
export const calculateZoomFactor = (currentDistance: number, startDistance: number): number =>
startDistance > 0 ? currentDistance / startDistance : 1;

26
src/utils/math.ts Normal file
View file

@ -0,0 +1,26 @@
export const clamp = (value: number, min: number, max: number): number => {
return Math.max(min, Math.min(max, value));
};
export const normalize = (value: number, min: number, max: number): number => {
return (value - min) / (max - min);
};
export const lerp = (start: number, end: number, t: number): number => {
return start + (end - start) * t;
};
export const calculateDistance = (
x1: number,
y1: number,
x2: number,
y2: number
): number => {
const dx = x2 - x1;
const dy = y2 - y1;
return Math.hypot(dx, dy);
};
export const average = (numbers: number[]): number => {
return numbers.length ? numbers.reduce((sum, val) => sum + val, 0) / numbers.length : 0;
};

36
src/utils/settings.ts Normal file
View file

@ -0,0 +1,36 @@
import { AccelerationSettings, CONSTANTS } from '../types';
import { lerp } from './math';
export const updateSettingsCurve = (
settings: AccelerationSettings,
index: number,
value: number
): AccelerationSettings => {
const currentCurve = settings.accelerationCurve ?? new Array(CONSTANTS.NUM_CURVE_POINTS).fill(1);
return {
...settings,
accelerationCurve: currentCurve.map((v, i) => i === index ? value : v)
};
};
export const createDefaultSettings = (): AccelerationSettings => ({
enabled: false,
accelerationCurve: new Array(CONSTANTS.NUM_CURVE_POINTS).fill(1)
});
export const interpolateCurveValue = (
accelerationCurve: number[],
normalizedSpeed: number
): number => {
if (!accelerationCurve || accelerationCurve.length === 0) return 1;
const curveIndex = normalizedSpeed * (accelerationCurve.length - 1);
const lowerIndex = Math.floor(curveIndex);
const upperIndex = Math.min(accelerationCurve.length - 1, Math.ceil(curveIndex));
const interpolationFactor = curveIndex - lowerIndex;
return lerp(
accelerationCurve[lowerIndex],
accelerationCurve[upperIndex],
interpolationFactor
);
};

26
src/utils/stats.ts Normal file
View file

@ -0,0 +1,26 @@
import { Stats, Round, AccelerationSettings } from '../types';
export const calculateAverageTime = (times: number[]): number => {
return times.length
? Math.round(times.reduce((a, b) => a + b) / times.length)
: 0;
};
export const createNewRound = (
time: number,
misclicks: number,
settings: AccelerationSettings
): Round => ({
timeSpent: time,
misclicks,
accelerationEnabled: settings.enabled,
accelerationCurve: settings.enabled ? [...settings.accelerationCurve] : undefined,
timestamp: Date.now(),
});
export const createInitialStats = (): Stats => ({
startTime: Date.now(),
misclicks: 0,
targetsHit: 0,
times: [],
});

49
src/utils/storage.ts Normal file
View file

@ -0,0 +1,49 @@
import { Round, Stats, AccelerationSettings } from '../types';
interface StorageState {
rounds: Round[];
stats: Stats;
settings: AccelerationSettings;
}
const STORAGE_KEY = 'zoomaccell_state';
export const saveState = (state: StorageState): void => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (error) {
console.error('Failed to save state:', error);
}
};
export const loadState = (): StorageState | null => {
try {
const savedState = localStorage.getItem(STORAGE_KEY);
return savedState ? JSON.parse(savedState) : null;
} catch (error) {
console.error('Failed to load state:', error);
return null;
}
};
export const exportToCSV = (rounds: Round[]): void => {
const headers = ['Round', 'Time (ms)', 'Misclicks', 'Acceleration', 'Timestamp'];
const rows = rounds.map((round, index) => [
index + 1,
round.timeSpent,
round.misclicks,
round.accelerationEnabled ? 'On' : 'Off',
new Date(round.timestamp).toISOString()
]);
const csvContent = [
headers.join(','),
...rows.map(row => row.join(','))
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `zoom_acceleration_results_${new Date().toISOString()}.csv`;
link.click();
};

55
src/utils/target.ts Normal file
View file

@ -0,0 +1,55 @@
import { Target, CONSTANTS } from '../types';
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));
const numRows = Math.ceil(CONSTANTS.TARGET_COUNT / numColumns);
for (let i = 0; i < CONSTANTS.TARGET_COUNT; i++) {
let validPosition = false;
let newTarget: Target;
// Calculate the grid position
const column = i % numColumns;
const row = Math.floor(i / numColumns);
while (!validPosition) {
const radius = CONSTANTS.TARGET_MIN_RADIUS +
Math.random() * (CONSTANTS.TARGET_MAX_RADIUS - CONSTANTS.TARGET_MIN_RADIUS);
// Base position from grid
const baseX = PADDING + column * segmentWidth;
const baseY = PADDING + row * segmentWidth;
// Add some randomness within the cell
const x = baseX + Math.random() * (segmentWidth - radius * 2);
const y = baseY + Math.random() * (segmentWidth - radius * 2);
newTarget = { x, y, radius };
validPosition = true;
// Check for overlaps
for (const existing of newTargets) {
const distance = Math.hypot(existing.x - x, existing.y - y);
if (distance < (existing.radius + radius) * 2) {
validPosition = false;
break;
}
}
}
newTargets.push(newTarget!);
}
return newTargets;
};
export const getRandomTarget = (currentTarget: number | null, maxTargets: number): number => {
let newTarget: number;
do {
newTarget = Math.floor(Math.random() * maxTargets);
} while (newTarget === currentTarget);
return newTarget;
};

7
src/utils/touch.ts Normal file
View file

@ -0,0 +1,7 @@
import { calculateDistance } from './math';
export const calculateTouchDistance = (touch1: Touch, touch2: Touch): number =>
calculateDistance(touch1.clientX, touch1.clientY, touch2.clientX, touch2.clientY);
export const calculateZoomFactor = (currentDistance: number, startDistance: number): number =>
startDistance > 0 ? currentDistance / startDistance : 1;

19
src/utils/zoom.ts Normal file
View file

@ -0,0 +1,19 @@
import { ViewportOffset, CONSTANTS } from '../types';
import { clamp } from './math';
export const calculateZoomDelta = (
wheelDelta: number,
accelerationFactor: number,
baseMultiplier = 0.001
): number => {
return clamp(wheelDelta * baseMultiplier * accelerationFactor, -0.5, 0.5);
};
export const calculateInitialViewport = (initialZoom: number): ViewportOffset => ({
x: (CONSTANTS.VIRTUAL_CANVAS_SIZE - window.innerWidth / initialZoom) / 2,
y: (CONSTANTS.VIRTUAL_CANVAS_SIZE - window.innerHeight / initialZoom) / 2,
});
export const clampZoom = (zoom: number): number => {
return Math.max(CONSTANTS.ZOOM_LEVELS.min, Math.min(CONSTANTS.ZOOM_LEVELS.max, zoom));
};