- {targets.map((target, i) => (
+ {targets.map((target, index) => (
void;
+}
+
+export const Instructions: React.FC
= ({ visible, onClose }) => {
+ if (!visible) return null;
+
+ return (
+
+
e.stopPropagation()}>
+
+
Welcome to Zoom Acceleration!
+
+
+
+
+ This is a target acquisition test that measures your performance with different zoom acceleration curves.
+
+
+
+ -
+ Click the green targets as quickly and accurately as possible
+
+ -
+ Use Ctrl + Scroll or ⌘ + Scroll to zoom
+
+ -
+ Click and drag to pan the view
+
+ -
+ Toggle zoom acceleration and customize the curve in Settings ⚙️
+
+ -
+ View your performance history and export results using the History panel 📊
+
+
+
+
+ Try different acceleration curves to find what works best for you!
+
+
+
+
+
+
+
+
+ );
+};
\ 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