From 94abfe82a912035b7be1948b70ec763d1c138d03 Mon Sep 17 00:00:00 2001 From: Atridad Lahiji Date: Thu, 13 Feb 2025 23:54:04 -0600 Subject: [PATCH] Huge re-factor...I have too much time on my hands... --- index.html | 2 +- package.json | 14 +- pnpm-lock.yaml | 14 +- src/App.css | 2 +- src/App.tsx | 359 ++++++++++++++------------------ src/components/Instructions.css | 80 +++++++ src/components/Instructions.tsx | 55 +++++ src/components/Settings.css | 24 ++- src/components/Settings.tsx | 64 +++++- src/types.ts | 21 +- src/utils/acceleration.ts | 33 +++ src/utils/interaction.ts | 24 +++ src/utils/math.ts | 26 +++ src/utils/settings.ts | 36 ++++ src/utils/stats.ts | 26 +++ src/utils/storage.ts | 49 +++++ src/utils/target.ts | 55 +++++ src/utils/touch.ts | 7 + src/utils/zoom.ts | 19 ++ 19 files changed, 674 insertions(+), 236 deletions(-) create mode 100644 src/components/Instructions.css create mode 100644 src/components/Instructions.tsx create mode 100644 src/utils/acceleration.ts create mode 100644 src/utils/interaction.ts create mode 100644 src/utils/math.ts create mode 100644 src/utils/settings.ts create mode 100644 src/utils/stats.ts create mode 100644 src/utils/storage.ts create mode 100644 src/utils/target.ts create mode 100644 src/utils/touch.ts create mode 100644 src/utils/zoom.ts diff --git a/index.html b/index.html index 6810d54..928f79e 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - ✨ ZoomAccel ✨ + ✨ ZoomAccel ✨
diff --git a/package.json b/package.json index 39c25d4..a7610ae 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b4cc8b..bf79caa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/App.css b/src/App.css index 791ad69..be887b6 100644 --- a/src/App.css +++ b/src/App.css @@ -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; diff --git a/src/App.tsx b/src/App.tsx index 377e55b..f28b439 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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([]); const [currentTarget, setCurrentTarget] = useState(null); - const [zoom, setZoom] = useState(CONSTANTS.ZOOM_LEVELS.default); - const [stats, setStats] = useState({ - startTime: Date.now(), - misclicks: 0, - targetsHit: 0, - times: [], - }); + const [zoomLevel, setZoomLevel] = useState(CONSTANTS.ZOOM_LEVELS.default); + const [stats, setStats] = useState(createInitialStats()); // Load settings from localStorage or use defaults const [settings, setSettings] = useState(() => { 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(() => { @@ -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({ 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(null); - const wheelDeltaSamples = useRef([]); + const zoomSpeedSamples = useRef([]); const touchStartDistance = useRef(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 rect = container.getBoundingClientRect(); - const cursorX = e.clientX - rect.left; - const cursorY = e.clientY - rect.top; + const handleWheelEvent = (event: WheelEvent) => { + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + + 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) => { + 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) => { + 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 (
@@ -317,7 +274,7 @@ function App() { +
+
+ + ); +}; \ No newline at end of file diff --git a/src/components/Settings.css b/src/components/Settings.css index 35ab6cc..c371320 100644 --- a/src/components/Settings.css +++ b/src/components/Settings.css @@ -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 { diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index a5f1d75..314a02f 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -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 = ({ onClose, }) => { const numSliders = 5; + const [editingValue, setEditingValue] = useState( + 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, 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 = ({ 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} /> - {settings.curve[i].toFixed(1)}x + handleInputChange(i, e.target.value)} + onBlur={() => handleInputBlur(i)} + onKeyDown={(e) => handleInputKeyDown(e, i)} + disabled={!settings.enabled} + placeholder={(settings.accelerationCurve?.[i] ?? 1).toFixed(1)} + /> ))} diff --git a/src/types.ts b/src/types.ts index 0c08923..989592e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; diff --git a/src/utils/acceleration.ts b/src/utils/acceleration.ts new file mode 100644 index 0000000..adc10a3 --- /dev/null +++ b/src/utils/acceleration.ts @@ -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; +}; diff --git a/src/utils/interaction.ts b/src/utils/interaction.ts new file mode 100644 index 0000000..160d5bf --- /dev/null +++ b/src/utils/interaction.ts @@ -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; \ No newline at end of file diff --git a/src/utils/math.ts b/src/utils/math.ts new file mode 100644 index 0000000..4056b09 --- /dev/null +++ b/src/utils/math.ts @@ -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; +}; diff --git a/src/utils/settings.ts b/src/utils/settings.ts new file mode 100644 index 0000000..25bf9f4 --- /dev/null +++ b/src/utils/settings.ts @@ -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 + ); +}; diff --git a/src/utils/stats.ts b/src/utils/stats.ts new file mode 100644 index 0000000..73f5b34 --- /dev/null +++ b/src/utils/stats.ts @@ -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: [], +}); diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..7821a75 --- /dev/null +++ b/src/utils/storage.ts @@ -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(); +}; \ No newline at end of file diff --git a/src/utils/target.ts b/src/utils/target.ts new file mode 100644 index 0000000..7097122 --- /dev/null +++ b/src/utils/target.ts @@ -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; +}; diff --git a/src/utils/touch.ts b/src/utils/touch.ts new file mode 100644 index 0000000..f1e692f --- /dev/null +++ b/src/utils/touch.ts @@ -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; diff --git a/src/utils/zoom.ts b/src/utils/zoom.ts new file mode 100644 index 0000000..6eaa0a3 --- /dev/null +++ b/src/utils/zoom.ts @@ -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)); +}; \ No newline at end of file