Huge re-factor...I have too much time on my hands...
This commit is contained in:
parent
398b2df042
commit
94abfe82a9
19 changed files with 674 additions and 236 deletions
14
package.json
14
package.json
|
@ -14,16 +14,16 @@
|
||||||
"react-dom": "^19.0.0"
|
"react-dom": "^19.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.19.0",
|
"@eslint/js": "^9.20.0",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"eslint": "^9.19.0",
|
"eslint": "^9.20.1",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.1.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.18",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.15.0",
|
||||||
"typescript": "~5.7.2",
|
"typescript": "~5.7.3",
|
||||||
"typescript-eslint": "^8.22.0",
|
"typescript-eslint": "^8.24.0",
|
||||||
"vite": "^6.1.0"
|
"vite": "^6.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
|
@ -16,7 +16,7 @@ importers:
|
||||||
version: 19.0.0(react@19.0.0)
|
version: 19.0.0(react@19.0.0)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/js':
|
'@eslint/js':
|
||||||
specifier: ^9.19.0
|
specifier: ^9.20.0
|
||||||
version: 9.20.0
|
version: 9.20.0
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^19.0.8
|
specifier: ^19.0.8
|
||||||
|
@ -28,22 +28,22 @@ importers:
|
||||||
specifier: ^4.3.4
|
specifier: ^4.3.4
|
||||||
version: 4.3.4(vite@6.1.0)
|
version: 4.3.4(vite@6.1.0)
|
||||||
eslint:
|
eslint:
|
||||||
specifier: ^9.19.0
|
specifier: ^9.20.1
|
||||||
version: 9.20.1
|
version: 9.20.1
|
||||||
eslint-plugin-react-hooks:
|
eslint-plugin-react-hooks:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.1.0
|
||||||
version: 5.1.0(eslint@9.20.1)
|
version: 5.1.0(eslint@9.20.1)
|
||||||
eslint-plugin-react-refresh:
|
eslint-plugin-react-refresh:
|
||||||
specifier: ^0.4.18
|
specifier: ^0.4.19
|
||||||
version: 0.4.19(eslint@9.20.1)
|
version: 0.4.19(eslint@9.20.1)
|
||||||
globals:
|
globals:
|
||||||
specifier: ^15.14.0
|
specifier: ^15.15.0
|
||||||
version: 15.15.0
|
version: 15.15.0
|
||||||
typescript:
|
typescript:
|
||||||
specifier: ~5.7.2
|
specifier: ~5.7.3
|
||||||
version: 5.7.3
|
version: 5.7.3
|
||||||
typescript-eslint:
|
typescript-eslint:
|
||||||
specifier: ^8.22.0
|
specifier: ^8.24.0
|
||||||
version: 8.24.0(eslint@9.20.1)(typescript@5.7.3)
|
version: 8.24.0(eslint@9.20.1)(typescript@5.7.3)
|
||||||
vite:
|
vite:
|
||||||
specifier: ^6.1.0
|
specifier: ^6.1.0
|
||||||
|
|
|
@ -41,7 +41,7 @@
|
||||||
.content {
|
.content {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 10000px;
|
width: 10000px;
|
||||||
/* Match VIRTUAL_SIZE */
|
/* Match VIRTUAL_CANVAS_SIZE */
|
||||||
height: 10000px;
|
height: 10000px;
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
background-color: white;
|
background-color: white;
|
||||||
|
|
357
src/App.tsx
357
src/App.tsx
|
@ -1,7 +1,8 @@
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Settings } from './components/Settings';
|
import { Settings } from './components/Settings';
|
||||||
import { RoundsHistory } from './components/RoundsHistory';
|
import { RoundsHistory } from './components/RoundsHistory';
|
||||||
import { saveState, loadState, exportToCSV } from './utils';
|
import { Instructions } from './components/Instructions';
|
||||||
|
import { saveState, loadState, exportToCSV } from './utils/storage';
|
||||||
import {
|
import {
|
||||||
Target,
|
Target,
|
||||||
Stats,
|
Stats,
|
||||||
|
@ -10,27 +11,35 @@ import {
|
||||||
Round,
|
Round,
|
||||||
CONSTANTS,
|
CONSTANTS,
|
||||||
} from './types';
|
} 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';
|
import './App.css';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Core state
|
// Core state
|
||||||
const [targets, setTargets] = useState<Target[]>([]);
|
const [targets, setTargets] = useState<Target[]>([]);
|
||||||
const [currentTarget, setCurrentTarget] = useState<number | null>(null);
|
const [currentTarget, setCurrentTarget] = useState<number | null>(null);
|
||||||
const [zoom, setZoom] = useState<number>(CONSTANTS.ZOOM_LEVELS.default);
|
const [zoomLevel, setZoomLevel] = useState<number>(CONSTANTS.ZOOM_LEVELS.default);
|
||||||
const [stats, setStats] = useState<Stats>({
|
const [stats, setStats] = useState<Stats>(createInitialStats());
|
||||||
startTime: Date.now(),
|
|
||||||
misclicks: 0,
|
|
||||||
targetsHit: 0,
|
|
||||||
times: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
// Load settings from localStorage or use defaults
|
// Load settings from localStorage or use defaults
|
||||||
const [settings, setSettings] = useState<AccelerationSettings>(() => {
|
const [settings, setSettings] = useState<AccelerationSettings>(() => {
|
||||||
const savedState = loadState();
|
const savedState = loadState();
|
||||||
return savedState?.settings ?? {
|
const defaultSettings = createDefaultSettings();
|
||||||
enabled: true,
|
return savedState?.settings ?? defaultSettings;
|
||||||
curve: Array(CONSTANTS.NUM_CURVE_POINTS).fill(1),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [rounds, setRounds] = useState<Round[]>(() => {
|
const [rounds, setRounds] = useState<Round[]>(() => {
|
||||||
|
@ -41,21 +50,30 @@ function App() {
|
||||||
// UI state
|
// UI state
|
||||||
const [showSettings, setShowSettings] = useState(false);
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
const [showRounds, setShowRounds] = useState(false);
|
const [showRounds, setShowRounds] = useState(false);
|
||||||
|
const [showInstructions, setShowInstructions] = useState(false);
|
||||||
const [viewportOffset, setViewportOffset] = useState<ViewportOffset>({
|
const [viewportOffset, setViewportOffset] = useState<ViewportOffset>({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 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
|
// Refs for handling zoom behavior
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const wheelDeltaSamples = useRef<number[]>([]);
|
const zoomSpeedSamples = useRef<number[]>([]);
|
||||||
const touchStartDistance = useRef<number>(0);
|
const touchStartDistance = useRef<number>(0);
|
||||||
|
const wasReset = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedState = loadState();
|
const savedState = loadState();
|
||||||
if (savedState?.stats) {
|
if (savedState?.stats) {
|
||||||
setStats(savedState.stats);
|
setStats(savedState.stats);
|
||||||
}
|
}
|
||||||
|
setShowInstructions(!savedState?.rounds || savedState.rounds.length === 0);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -67,70 +85,64 @@ function App() {
|
||||||
}, [rounds, stats, settings]);
|
}, [rounds, stats, settings]);
|
||||||
|
|
||||||
useEffect(() => {
|
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(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
const handleWheelEvent = (e: WheelEvent) => {
|
const handleWheelEvent = (event: WheelEvent) => {
|
||||||
if (e.ctrlKey || e.metaKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const rect = container.getBoundingClientRect();
|
const containerBounds = container.getBoundingClientRect();
|
||||||
const cursorX = e.clientX - rect.left;
|
const cursorX = event.clientX - containerBounds.left;
|
||||||
const cursorY = e.clientY - rect.top;
|
const cursorY = event.clientY - containerBounds.top;
|
||||||
|
|
||||||
const virtualX = viewportOffset.x + cursorX / zoom;
|
const worldX = viewportOffset.x + cursorX / zoomLevel;
|
||||||
const virtualY = viewportOffset.y + cursorY / zoom;
|
const worldY = viewportOffset.y + cursorY / zoomLevel;
|
||||||
|
|
||||||
// Prevent crazy fast scrolling
|
const scrollAmount = processWheelDelta(-event.deltaY);
|
||||||
const deltaY = Math.max(-100, Math.min(100, -e.deltaY));
|
|
||||||
|
|
||||||
if (isFinite(deltaY)) {
|
if (isFinite(scrollAmount)) {
|
||||||
wheelDeltaSamples.current.push(Math.abs(deltaY));
|
zoomSpeedSamples.current = updateWheelSamples(zoomSpeedSamples.current, scrollAmount);
|
||||||
if (wheelDeltaSamples.current.length > CONSTANTS.WHEEL_SAMPLES) {
|
|
||||||
wheelDeltaSamples.current.shift();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let zoomDelta = deltaY * 0.001;
|
let zoomDelta = scrollAmount * 0.001;
|
||||||
|
|
||||||
// Apply acceleration curve if enabled
|
if (settings.enabled && zoomSpeedSamples.current.length > 0) {
|
||||||
if (settings.enabled && wheelDeltaSamples.current.length > 0) {
|
const accelerationFactor = calculateAccelerationFactor(
|
||||||
const averageSpeed = wheelDeltaSamples.current.reduce((a, b) => a + b, 0) /
|
zoomSpeedSamples.current,
|
||||||
wheelDeltaSamples.current.length;
|
settings.accelerationCurve,
|
||||||
|
settings.enabled
|
||||||
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 (isFinite(accelerationFactor)) {
|
if (isFinite(accelerationFactor)) {
|
||||||
zoomDelta *= 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) {
|
if (isFinite(newZoom) && newZoom > 0) {
|
||||||
const newOffsetX = virtualX - cursorX / newZoom;
|
const newOffsetX = worldX - cursorX / newZoom;
|
||||||
const newOffsetY = virtualY - cursorY / newZoom;
|
const newOffsetY = worldY - cursorY / newZoom;
|
||||||
|
|
||||||
if (isFinite(newOffsetX) && isFinite(newOffsetY)) {
|
if (isFinite(newOffsetX) && isFinite(newOffsetY)) {
|
||||||
setZoom(newZoom);
|
setZoomLevel(newZoom);
|
||||||
setViewportOffset({ x: newOffsetX, y: newOffsetY });
|
setViewportOffset({ x: newOffsetX, y: newOffsetY });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -138,87 +150,25 @@ function App() {
|
||||||
};
|
};
|
||||||
|
|
||||||
container.addEventListener('wheel', handleWheelEvent, { passive: false });
|
container.addEventListener('wheel', handleWheelEvent, { passive: false });
|
||||||
|
return () => container.removeEventListener('wheel', handleWheelEvent);
|
||||||
|
}, [zoomLevel, viewportOffset, settings]);
|
||||||
|
|
||||||
return () => {
|
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||||
container.removeEventListener('wheel', handleWheelEvent);
|
if (event.touches.length === 2) {
|
||||||
};
|
const touch1 = event.touches[0];
|
||||||
}, [zoom, viewportOffset, settings]);
|
const touch2 = event.touches[1];
|
||||||
|
touchStartDistance.current = calculateTouchDistance(touch1, touch2);
|
||||||
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 handleTouchMove = (e: React.TouchEvent) => {
|
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||||
if (e.touches.length === 2) {
|
if (event.touches.length === 2) {
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
const touch1 = e.touches[0];
|
const touch1 = event.touches[0];
|
||||||
const touch2 = e.touches[1];
|
const touch2 = event.touches[1];
|
||||||
const currentDistance = Math.hypot(
|
const currentDistance = calculateTouchDistance(touch1, touch2);
|
||||||
touch2.clientX - touch1.clientX,
|
const zoomFactor = calculateZoomFactor(currentDistance, touchStartDistance.current);
|
||||||
touch2.clientY - touch1.clientY
|
setZoomLevel(previous => clampZoom(previous * zoomFactor));
|
||||||
);
|
|
||||||
|
|
||||||
const zoomFactor = currentDistance / touchStartDistance.current;
|
|
||||||
setZoom((prev) =>
|
|
||||||
Math.max(
|
|
||||||
CONSTANTS.ZOOM_LEVELS.min,
|
|
||||||
Math.min(CONSTANTS.ZOOM_LEVELS.max, prev * zoomFactor)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -226,78 +176,85 @@ function App() {
|
||||||
touchStartDistance.current = 0;
|
touchStartDistance.current = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleMouseMove = (event: React.MouseEvent) => {
|
||||||
if (!containerRef.current || currentTarget === null) return;
|
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();
|
// Only set dragging if we've moved past the threshold
|
||||||
const x = (e.clientX - rect.left) / zoom + viewportOffset.x;
|
if (dragDistance > DRAG_THRESHOLD) {
|
||||||
const y = (e.clientY - rect.top) / zoom + viewportOffset.y;
|
isDragging.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
const target = targets[currentTarget];
|
setViewportOffset(previous => ({
|
||||||
if (!target) return;
|
x: previous.x - event.movementX / zoomLevel,
|
||||||
|
y: previous.y - event.movementY / zoomLevel,
|
||||||
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,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseMove = (e: React.MouseEvent) => {
|
const handleMouseDown = (event: React.MouseEvent) => {
|
||||||
if (e.buttons === 1) {
|
dragStartTime.current = Date.now();
|
||||||
setViewportOffset((prev) => ({
|
dragStartPos.current = { x: event.clientX, y: event.clientY };
|
||||||
x: prev.x - e.movementX / zoom,
|
isDragging.current = false;
|
||||||
y: prev.y - e.movementY / zoom,
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
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([]);
|
setRounds([]);
|
||||||
setStats({
|
setStats(createInitialStats());
|
||||||
startTime: Date.now(),
|
setSettings(createDefaultSettings());
|
||||||
misclicks: 0,
|
initializeGame();
|
||||||
targetsHit: 0,
|
wasReset.current = true;
|
||||||
times: [],
|
setShowInstructions(true);
|
||||||
});
|
|
||||||
generateTargets();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExport = () => {
|
|
||||||
exportToCSV(rounds);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
<div className="controls">
|
<div className="controls">
|
||||||
|
@ -317,7 +274,7 @@ function App() {
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="export-button"
|
className="export-button"
|
||||||
onClick={handleExport}
|
onClick={() => exportToCSV(rounds)}
|
||||||
disabled={rounds.length === 0}
|
disabled={rounds.length === 0}
|
||||||
>
|
>
|
||||||
📥 Export CSV
|
📥 Export CSV
|
||||||
|
@ -344,13 +301,16 @@ function App() {
|
||||||
onClose={() => setShowRounds(false)}
|
onClose={() => setShowRounds(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Instructions
|
||||||
|
visible={showInstructions}
|
||||||
|
onClose={() => setShowInstructions(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="stats">
|
<div className="stats">
|
||||||
<span>Targets: {stats.targetsHit}</span> •
|
<span>Targets: {stats.targetsHit}</span> •
|
||||||
<span>Misclicks: {stats.misclicks}</span> •
|
<span>Misclicks: {stats.misclicks}</span> •
|
||||||
<span>Avg Time: {stats.times.length
|
<span>Avg Time: {calculateAverageTime(stats.times)} ms</span> •
|
||||||
? Math.round(stats.times.reduce((a, b) => a + b) / stats.times.length)
|
<span>Zoom: {zoomLevel.toFixed(2)}x</span>
|
||||||
: 0} ms</span> •
|
|
||||||
<span>Zoom: {zoom.toFixed(2)}x</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
@ -361,19 +321,20 @@ function App() {
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseUp={handleMouseUp}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="content"
|
className="content"
|
||||||
style={{
|
style={{
|
||||||
transform: `translate(${-viewportOffset.x * zoom}px, ${-viewportOffset.y * zoom
|
transform: `translate(${-viewportOffset.x * zoomLevel}px, ${-viewportOffset.y * zoomLevel}px) scale(${zoomLevel})`,
|
||||||
}px) scale(${zoom})`,
|
|
||||||
transformOrigin: '0 0',
|
transformOrigin: '0 0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{targets.map((target, i) => (
|
{targets.map((target, index) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={index}
|
||||||
className={`target ${i === currentTarget ? 'active' : ''}`}
|
className={`target ${index === currentTarget ? 'active' : ''}`}
|
||||||
style={{
|
style={{
|
||||||
left: `${target.x}px`,
|
left: `${target.x}px`,
|
||||||
top: `${target.y}px`,
|
top: `${target.y}px`,
|
||||||
|
|
80
src/components/Instructions.css
Normal file
80
src/components/Instructions.css
Normal 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;
|
||||||
|
}
|
55
src/components/Instructions.tsx
Normal file
55
src/components/Instructions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -57,11 +57,27 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider-row span {
|
.slider-row .value-input {
|
||||||
width: 40px;
|
width: 50px;
|
||||||
text-align: right;
|
text-align: center;
|
||||||
font-size: 14px;
|
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 {
|
.acceleration-toggle {
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { AccelerationSettings } from '../types';
|
import { AccelerationSettings } from '../types';
|
||||||
|
import { updateSettingsCurve } from '../utils/settings';
|
||||||
|
import { clamp } from '../utils/math';
|
||||||
import './Settings.css';
|
import './Settings.css';
|
||||||
|
|
||||||
interface SettingsProps {
|
interface SettingsProps {
|
||||||
|
@ -16,11 +18,52 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const numSliders = 5;
|
const numSliders = 5;
|
||||||
|
const [editingValue, setEditingValue] = useState<string[]>(
|
||||||
|
new Array(numSliders).fill('')
|
||||||
|
);
|
||||||
|
|
||||||
const handleSliderChange = (index: number, value: number) => {
|
const handleSliderChange = (index: number, value: number) => {
|
||||||
const newCurve = [...settings.curve];
|
const newSettings = updateSettingsCurve(settings, index, value);
|
||||||
newCurve[index] = value;
|
onSettingsChange(newSettings);
|
||||||
onSettingsChange({ ...settings, curve: newCurve });
|
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;
|
if (!visible) return null;
|
||||||
|
@ -55,11 +98,20 @@ export const Settings: React.FC<SettingsProps> = ({
|
||||||
min="1"
|
min="1"
|
||||||
max="10"
|
max="10"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
value={settings.curve[i]}
|
value={settings.accelerationCurve?.[i] ?? 1}
|
||||||
onChange={(e) => handleSliderChange(i, parseFloat(e.target.value))}
|
onChange={(e) => handleSliderChange(i, parseFloat(e.target.value))}
|
||||||
disabled={!settings.enabled}
|
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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
21
src/types.ts
21
src/types.ts
|
@ -18,28 +18,27 @@ export interface ViewportOffset {
|
||||||
|
|
||||||
export interface AccelerationSettings {
|
export interface AccelerationSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
curve: number[];
|
accelerationCurve: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Round {
|
export interface Round {
|
||||||
timeSpent: number;
|
timeSpent: number;
|
||||||
misclicks: number;
|
misclicks: number;
|
||||||
accelerationEnabled: boolean;
|
accelerationEnabled: boolean;
|
||||||
accelerationCurve?: number[]; // Only present when acceleration is enabled
|
accelerationCurve?: number[];
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Game constants
|
|
||||||
export const CONSTANTS = {
|
export const CONSTANTS = {
|
||||||
NUM_TARGETS: 50,
|
TARGET_COUNT: 200,
|
||||||
MIN_RADIUS: 20,
|
TARGET_MIN_RADIUS: 100,
|
||||||
MAX_RADIUS: 60,
|
TARGET_MAX_RADIUS: 800,
|
||||||
VIRTUAL_SIZE: 10000, // Size of the virtual canvas
|
VIRTUAL_CANVAS_SIZE: 50000,
|
||||||
ZOOM_LEVELS: {
|
ZOOM_LEVELS: {
|
||||||
min: 0.1,
|
min: 0.01,
|
||||||
max: 10,
|
max: 10,
|
||||||
default: 1,
|
default: 0.2,
|
||||||
},
|
},
|
||||||
WHEEL_SAMPLES: 5, // Number of samples to average for smooth zooming
|
MOUSE_WHEEL_SAMPLES: 5,
|
||||||
NUM_CURVE_POINTS: 5, // Points in the acceleration curve
|
NUM_CURVE_POINTS: 5,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
33
src/utils/acceleration.ts
Normal file
33
src/utils/acceleration.ts
Normal 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
24
src/utils/interaction.ts
Normal 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
26
src/utils/math.ts
Normal 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
36
src/utils/settings.ts
Normal 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
26
src/utils/stats.ts
Normal 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
49
src/utils/storage.ts
Normal 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
55
src/utils/target.ts
Normal 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
7
src/utils/touch.ts
Normal 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
19
src/utils/zoom.ts
Normal 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));
|
||||||
|
};
|
Loading…
Add table
Reference in a new issue