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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.19.0",
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@types/react": "^19.0.8",
|
||||
"@types/react-dom": "^19.0.3",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.19.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.18",
|
||||
"globals": "^15.14.0",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.22.0",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^15.15.0",
|
||||
"typescript": "~5.7.3",
|
||||
"typescript-eslint": "^8.24.0",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
}
|
||||
|
|
14
pnpm-lock.yaml
generated
14
pnpm-lock.yaml
generated
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
357
src/App.tsx
357
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<Target[]>([]);
|
||||
const [currentTarget, setCurrentTarget] = useState<number | null>(null);
|
||||
const [zoom, setZoom] = useState<number>(CONSTANTS.ZOOM_LEVELS.default);
|
||||
const [stats, setStats] = useState<Stats>({
|
||||
startTime: Date.now(),
|
||||
misclicks: 0,
|
||||
targetsHit: 0,
|
||||
times: [],
|
||||
});
|
||||
const [zoomLevel, setZoomLevel] = useState<number>(CONSTANTS.ZOOM_LEVELS.default);
|
||||
const [stats, setStats] = useState<Stats>(createInitialStats());
|
||||
|
||||
// Load settings from localStorage or use defaults
|
||||
const [settings, setSettings] = useState<AccelerationSettings>(() => {
|
||||
const savedState = loadState();
|
||||
return savedState?.settings ?? {
|
||||
enabled: true,
|
||||
curve: Array(CONSTANTS.NUM_CURVE_POINTS).fill(1),
|
||||
};
|
||||
const defaultSettings = createDefaultSettings();
|
||||
return savedState?.settings ?? defaultSettings;
|
||||
});
|
||||
|
||||
const [rounds, setRounds] = useState<Round[]>(() => {
|
||||
|
@ -41,21 +50,30 @@ function App() {
|
|||
// UI state
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showRounds, setShowRounds] = useState(false);
|
||||
const [showInstructions, setShowInstructions] = useState(false);
|
||||
const [viewportOffset, setViewportOffset] = useState<ViewportOffset>({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
|
||||
// Add state to track drag operations
|
||||
const isDragging = useRef(false);
|
||||
const dragStartTime = useRef(0);
|
||||
const dragStartPos = useRef({ x: 0, y: 0 });
|
||||
const DRAG_THRESHOLD = 5; // pixels
|
||||
|
||||
// Refs for handling zoom behavior
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wheelDeltaSamples = useRef<number[]>([]);
|
||||
const zoomSpeedSamples = useRef<number[]>([]);
|
||||
const touchStartDistance = useRef<number>(0);
|
||||
const wasReset = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const savedState = loadState();
|
||||
if (savedState?.stats) {
|
||||
setStats(savedState.stats);
|
||||
}
|
||||
setShowInstructions(!savedState?.rounds || savedState.rounds.length === 0);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -67,70 +85,64 @@ function App() {
|
|||
}, [rounds, stats, settings]);
|
||||
|
||||
useEffect(() => {
|
||||
generateTargets();
|
||||
initializeGame();
|
||||
}, []);
|
||||
|
||||
const initializeGame = () => {
|
||||
const newTargets = generateTargets();
|
||||
setTargets(newTargets);
|
||||
setCurrentTarget(getRandomTarget(null, CONSTANTS.TARGET_COUNT));
|
||||
|
||||
const initialZoom = 0.2;
|
||||
setZoomLevel(initialZoom);
|
||||
setViewportOffset(calculateInitialViewport(initialZoom));
|
||||
setStats(previous => ({ ...previous, startTime: Date.now() }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleWheelEvent = (e: WheelEvent) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
e.preventDefault();
|
||||
const handleWheelEvent = (event: WheelEvent) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const cursorX = e.clientX - rect.left;
|
||||
const cursorY = e.clientY - rect.top;
|
||||
const containerBounds = container.getBoundingClientRect();
|
||||
const cursorX = event.clientX - containerBounds.left;
|
||||
const cursorY = event.clientY - containerBounds.top;
|
||||
|
||||
const virtualX = viewportOffset.x + cursorX / zoom;
|
||||
const virtualY = viewportOffset.y + cursorY / zoom;
|
||||
const worldX = viewportOffset.x + cursorX / zoomLevel;
|
||||
const worldY = viewportOffset.y + cursorY / zoomLevel;
|
||||
|
||||
// Prevent crazy fast scrolling
|
||||
const deltaY = Math.max(-100, Math.min(100, -e.deltaY));
|
||||
const scrollAmount = processWheelDelta(-event.deltaY);
|
||||
|
||||
if (isFinite(deltaY)) {
|
||||
wheelDeltaSamples.current.push(Math.abs(deltaY));
|
||||
if (wheelDeltaSamples.current.length > CONSTANTS.WHEEL_SAMPLES) {
|
||||
wheelDeltaSamples.current.shift();
|
||||
}
|
||||
if (isFinite(scrollAmount)) {
|
||||
zoomSpeedSamples.current = updateWheelSamples(zoomSpeedSamples.current, scrollAmount);
|
||||
}
|
||||
|
||||
let zoomDelta = deltaY * 0.001;
|
||||
let zoomDelta = scrollAmount * 0.001;
|
||||
|
||||
// Apply acceleration curve if enabled
|
||||
if (settings.enabled && wheelDeltaSamples.current.length > 0) {
|
||||
const averageSpeed = wheelDeltaSamples.current.reduce((a, b) => a + b, 0) /
|
||||
wheelDeltaSamples.current.length;
|
||||
|
||||
const normalizedSpeed = Math.max(0, Math.min(2, averageSpeed / 100));
|
||||
const curveIndex = normalizedSpeed * (settings.curve.length - 1);
|
||||
const lowerIndex = Math.floor(curveIndex);
|
||||
const upperIndex = Math.min(settings.curve.length - 1, Math.ceil(curveIndex));
|
||||
const t = curveIndex - lowerIndex;
|
||||
|
||||
const accelerationFactor =
|
||||
settings.curve[lowerIndex] * (1 - t) +
|
||||
settings.curve[upperIndex] * t;
|
||||
if (settings.enabled && zoomSpeedSamples.current.length > 0) {
|
||||
const accelerationFactor = calculateAccelerationFactor(
|
||||
zoomSpeedSamples.current,
|
||||
settings.accelerationCurve,
|
||||
settings.enabled
|
||||
);
|
||||
|
||||
if (isFinite(accelerationFactor)) {
|
||||
zoomDelta *= accelerationFactor;
|
||||
}
|
||||
}
|
||||
|
||||
zoomDelta = Math.max(-0.5, Math.min(0.5, zoomDelta));
|
||||
zoomDelta = clamp(zoomDelta, -0.5, 0.5);
|
||||
const newZoom = clampZoom(zoomLevel * (1 + zoomDelta));
|
||||
|
||||
const newZoom = Math.max(
|
||||
CONSTANTS.ZOOM_LEVELS.min,
|
||||
Math.min(CONSTANTS.ZOOM_LEVELS.max, zoom * (1 + zoomDelta))
|
||||
);
|
||||
|
||||
// Sanity check before updating state
|
||||
if (isFinite(newZoom) && newZoom > 0) {
|
||||
const newOffsetX = virtualX - cursorX / newZoom;
|
||||
const newOffsetY = virtualY - cursorY / newZoom;
|
||||
const newOffsetX = worldX - cursorX / newZoom;
|
||||
const newOffsetY = worldY - cursorY / newZoom;
|
||||
|
||||
if (isFinite(newOffsetX) && isFinite(newOffsetY)) {
|
||||
setZoom(newZoom);
|
||||
setZoomLevel(newZoom);
|
||||
setViewportOffset({ x: newOffsetX, y: newOffsetY });
|
||||
}
|
||||
}
|
||||
|
@ -138,87 +150,25 @@ function App() {
|
|||
};
|
||||
|
||||
container.addEventListener('wheel', handleWheelEvent, { passive: false });
|
||||
return () => container.removeEventListener('wheel', handleWheelEvent);
|
||||
}, [zoomLevel, viewportOffset, settings]);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('wheel', handleWheelEvent);
|
||||
};
|
||||
}, [zoom, viewportOffset, settings]);
|
||||
|
||||
const generateTargets = () => {
|
||||
const newTargets: Target[] = [];
|
||||
const PADDING = CONSTANTS.MAX_RADIUS * 2;
|
||||
|
||||
const usableArea = {
|
||||
start: PADDING,
|
||||
end: CONSTANTS.VIRTUAL_SIZE - PADDING
|
||||
};
|
||||
|
||||
for (let i = 0; i < CONSTANTS.NUM_TARGETS; i++) {
|
||||
let validPosition = false;
|
||||
let newTarget: Target;
|
||||
|
||||
while (!validPosition) {
|
||||
const radius = CONSTANTS.MIN_RADIUS +
|
||||
Math.random() * (CONSTANTS.MAX_RADIUS - CONSTANTS.MIN_RADIUS);
|
||||
const x = usableArea.start + Math.random() * (usableArea.end - usableArea.start);
|
||||
const y = usableArea.start + Math.random() * (usableArea.end - usableArea.start);
|
||||
|
||||
newTarget = { x, y, radius };
|
||||
validPosition = true;
|
||||
|
||||
for (const existing of newTargets) {
|
||||
const distance = Math.hypot(existing.x - x, existing.y - y);
|
||||
if (distance < (existing.radius + radius) * 2) {
|
||||
validPosition = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
newTargets.push(newTarget!);
|
||||
}
|
||||
|
||||
setTargets(newTargets);
|
||||
setCurrentTarget(Math.floor(Math.random() * CONSTANTS.NUM_TARGETS));
|
||||
|
||||
const initialZoom = 0.2;
|
||||
setZoom(initialZoom);
|
||||
|
||||
setViewportOffset({
|
||||
x: (CONSTANTS.VIRTUAL_SIZE - window.innerWidth / initialZoom) / 2,
|
||||
y: (CONSTANTS.VIRTUAL_SIZE - window.innerHeight / initialZoom) / 2,
|
||||
});
|
||||
|
||||
setStats((prev) => ({ ...prev, startTime: Date.now() }));
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
touchStartDistance.current = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
);
|
||||
const handleTouchStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (event.touches.length === 2) {
|
||||
const touch1 = event.touches[0];
|
||||
const touch2 = event.touches[1];
|
||||
touchStartDistance.current = calculateTouchDistance(touch1, touch2);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
if (e.touches.length === 2) {
|
||||
e.preventDefault();
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
const currentDistance = Math.hypot(
|
||||
touch2.clientX - touch1.clientX,
|
||||
touch2.clientY - touch1.clientY
|
||||
);
|
||||
|
||||
const zoomFactor = currentDistance / touchStartDistance.current;
|
||||
setZoom((prev) =>
|
||||
Math.max(
|
||||
CONSTANTS.ZOOM_LEVELS.min,
|
||||
Math.min(CONSTANTS.ZOOM_LEVELS.max, prev * zoomFactor)
|
||||
)
|
||||
);
|
||||
const handleTouchMove = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (event.touches.length === 2) {
|
||||
event.preventDefault();
|
||||
const touch1 = event.touches[0];
|
||||
const touch2 = event.touches[1];
|
||||
const currentDistance = calculateTouchDistance(touch1, touch2);
|
||||
const zoomFactor = calculateZoomFactor(currentDistance, touchStartDistance.current);
|
||||
setZoomLevel(previous => clampZoom(previous * zoomFactor));
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -226,78 +176,85 @@ function App() {
|
|||
touchStartDistance.current = 0;
|
||||
};
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (!containerRef.current || currentTarget === null) return;
|
||||
const handleMouseMove = (event: React.MouseEvent) => {
|
||||
if (event.buttons === 1) {
|
||||
// Calculate total drag distance
|
||||
const dx = event.clientX - dragStartPos.current.x;
|
||||
const dy = event.clientY - dragStartPos.current.y;
|
||||
const dragDistance = Math.hypot(dx, dy);
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / zoom + viewportOffset.x;
|
||||
const y = (e.clientY - rect.top) / zoom + viewportOffset.y;
|
||||
// Only set dragging if we've moved past the threshold
|
||||
if (dragDistance > DRAG_THRESHOLD) {
|
||||
isDragging.current = true;
|
||||
}
|
||||
|
||||
const target = targets[currentTarget];
|
||||
if (!target) return;
|
||||
|
||||
const distance = Math.hypot(target.x - x, target.y - y);
|
||||
|
||||
if (distance <= target.radius) {
|
||||
const time = Date.now() - stats.startTime;
|
||||
|
||||
// Log the round with acceleration curve if enabled
|
||||
setRounds(prev => [...prev, {
|
||||
timeSpent: time,
|
||||
misclicks: stats.misclicks,
|
||||
accelerationEnabled: settings.enabled,
|
||||
accelerationCurve: settings.enabled ? [...settings.curve] : undefined,
|
||||
timestamp: Date.now(),
|
||||
}]);
|
||||
|
||||
// Reset stats for next round
|
||||
setStats({
|
||||
startTime: Date.now(),
|
||||
misclicks: 0,
|
||||
targetsHit: stats.targetsHit + 1,
|
||||
times: [...stats.times, time],
|
||||
});
|
||||
|
||||
// Select new target
|
||||
let newTarget: number;
|
||||
do {
|
||||
newTarget = Math.floor(Math.random() * CONSTANTS.NUM_TARGETS);
|
||||
} while (newTarget === currentTarget);
|
||||
setCurrentTarget(newTarget);
|
||||
} else {
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
misclicks: prev.misclicks + 1,
|
||||
setViewportOffset(previous => ({
|
||||
x: previous.x - event.movementX / zoomLevel,
|
||||
y: previous.y - event.movementY / zoomLevel,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (e.buttons === 1) {
|
||||
setViewportOffset((prev) => ({
|
||||
x: prev.x - e.movementX / zoom,
|
||||
y: prev.y - e.movementY / zoom,
|
||||
const handleMouseDown = (event: React.MouseEvent) => {
|
||||
dragStartTime.current = Date.now();
|
||||
dragStartPos.current = { x: event.clientX, y: event.clientY };
|
||||
isDragging.current = false;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
// Reset drag state after a short delay to allow click event to check it
|
||||
setTimeout(() => {
|
||||
isDragging.current = false;
|
||||
dragStartPos.current = { x: 0, y: 0 };
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleClick = (event: React.MouseEvent) => {
|
||||
if (!containerRef.current || currentTarget === null) return;
|
||||
|
||||
// If this was a drag operation or very slow click (likely a drag attempt), ignore it
|
||||
if (isDragging.current || Date.now() - dragStartTime.current > 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerBounds = containerRef.current.getBoundingClientRect();
|
||||
const worldX = (event.clientX - containerBounds.left) / zoomLevel + viewportOffset.x;
|
||||
const worldY = (event.clientY - containerBounds.top) / zoomLevel + viewportOffset.y;
|
||||
|
||||
const target = targets[currentTarget];
|
||||
if (!target) return;
|
||||
|
||||
const distanceToTarget = Math.hypot(target.x - worldX, target.y - worldY);
|
||||
|
||||
if (distanceToTarget <= target.radius) {
|
||||
const timeElapsed = Date.now() - stats.startTime;
|
||||
setRounds(previous => [...previous, createNewRound(timeElapsed, stats.misclicks, settings)]);
|
||||
setStats({
|
||||
startTime: Date.now(),
|
||||
misclicks: 0,
|
||||
targetsHit: stats.targetsHit + 1,
|
||||
times: [...stats.times, timeElapsed],
|
||||
});
|
||||
setCurrentTarget(getRandomTarget(currentTarget, CONSTANTS.TARGET_COUNT));
|
||||
} else {
|
||||
setStats(previous => ({
|
||||
...previous,
|
||||
misclicks: previous.misclicks + 1,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (window.confirm('Are you sure you want to reset all trials and history? This cannot be undone.')) {
|
||||
if (window.confirm('Are you sure you want to reset all trials, history, and settings? This cannot be undone.')) {
|
||||
setRounds([]);
|
||||
setStats({
|
||||
startTime: Date.now(),
|
||||
misclicks: 0,
|
||||
targetsHit: 0,
|
||||
times: [],
|
||||
});
|
||||
generateTargets();
|
||||
setStats(createInitialStats());
|
||||
setSettings(createDefaultSettings());
|
||||
initializeGame();
|
||||
wasReset.current = true;
|
||||
setShowInstructions(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
exportToCSV(rounds);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="controls">
|
||||
|
@ -317,7 +274,7 @@ function App() {
|
|||
|
||||
<button
|
||||
className="export-button"
|
||||
onClick={handleExport}
|
||||
onClick={() => exportToCSV(rounds)}
|
||||
disabled={rounds.length === 0}
|
||||
>
|
||||
📥 Export CSV
|
||||
|
@ -344,13 +301,16 @@ function App() {
|
|||
onClose={() => setShowRounds(false)}
|
||||
/>
|
||||
|
||||
<Instructions
|
||||
visible={showInstructions}
|
||||
onClose={() => setShowInstructions(false)}
|
||||
/>
|
||||
|
||||
<div className="stats">
|
||||
<span>Targets: {stats.targetsHit}</span> •
|
||||
<span>Misclicks: {stats.misclicks}</span> •
|
||||
<span>Avg Time: {stats.times.length
|
||||
? Math.round(stats.times.reduce((a, b) => a + b) / stats.times.length)
|
||||
: 0} ms</span> •
|
||||
<span>Zoom: {zoom.toFixed(2)}x</span>
|
||||
<span>Avg Time: {calculateAverageTime(stats.times)} ms</span> •
|
||||
<span>Zoom: {zoomLevel.toFixed(2)}x</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
@ -361,19 +321,20 @@ function App() {
|
|||
onTouchEnd={handleTouchEnd}
|
||||
onClick={handleClick}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
>
|
||||
<div
|
||||
className="content"
|
||||
style={{
|
||||
transform: `translate(${-viewportOffset.x * zoom}px, ${-viewportOffset.y * zoom
|
||||
}px) scale(${zoom})`,
|
||||
transform: `translate(${-viewportOffset.x * zoomLevel}px, ${-viewportOffset.y * zoomLevel}px) scale(${zoomLevel})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
{targets.map((target, i) => (
|
||||
{targets.map((target, index) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`target ${i === currentTarget ? 'active' : ''}`}
|
||||
key={index}
|
||||
className={`target ${index === currentTarget ? 'active' : ''}`}
|
||||
style={{
|
||||
left: `${target.x}px`,
|
||||
top: `${target.y}px`,
|
||||
|
|
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { AccelerationSettings } from '../types';
|
||||
import { updateSettingsCurve } from '../utils/settings';
|
||||
import { clamp } from '../utils/math';
|
||||
import './Settings.css';
|
||||
|
||||
interface SettingsProps {
|
||||
|
@ -16,11 +18,52 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||
onClose,
|
||||
}) => {
|
||||
const numSliders = 5;
|
||||
const [editingValue, setEditingValue] = useState<string[]>(
|
||||
new Array(numSliders).fill('')
|
||||
);
|
||||
|
||||
const handleSliderChange = (index: number, value: number) => {
|
||||
const newCurve = [...settings.curve];
|
||||
newCurve[index] = value;
|
||||
onSettingsChange({ ...settings, curve: newCurve });
|
||||
const newSettings = updateSettingsCurve(settings, index, value);
|
||||
onSettingsChange(newSettings);
|
||||
setEditingValue(prev => {
|
||||
const next = [...prev];
|
||||
next[index] = '';
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputChange = (index: number, value: string) => {
|
||||
setEditingValue(prev => {
|
||||
const next = [...prev];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputBlur = (index: number) => {
|
||||
const value = parseFloat(editingValue[index]);
|
||||
if (!isNaN(value)) {
|
||||
const clampedValue = clamp(value, 1, 10);
|
||||
handleSliderChange(index, clampedValue);
|
||||
}
|
||||
setEditingValue(prev => {
|
||||
const next = [...prev];
|
||||
next[index] = '';
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, index: number) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.currentTarget.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditingValue(prev => {
|
||||
const next = [...prev];
|
||||
next[index] = '';
|
||||
return next;
|
||||
});
|
||||
e.currentTarget.blur();
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
@ -55,11 +98,20 @@ export const Settings: React.FC<SettingsProps> = ({
|
|||
min="1"
|
||||
max="10"
|
||||
step="0.1"
|
||||
value={settings.curve[i]}
|
||||
value={settings.accelerationCurve?.[i] ?? 1}
|
||||
onChange={(e) => handleSliderChange(i, parseFloat(e.target.value))}
|
||||
disabled={!settings.enabled}
|
||||
/>
|
||||
<span>{settings.curve[i].toFixed(1)}x</span>
|
||||
<input
|
||||
type="text"
|
||||
className="value-input"
|
||||
value={editingValue[i] || (settings.accelerationCurve?.[i] ?? 1).toFixed(1)}
|
||||
onChange={(e) => handleInputChange(i, e.target.value)}
|
||||
onBlur={() => handleInputBlur(i)}
|
||||
onKeyDown={(e) => handleInputKeyDown(e, i)}
|
||||
disabled={!settings.enabled}
|
||||
placeholder={(settings.accelerationCurve?.[i] ?? 1).toFixed(1)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
|
21
src/types.ts
21
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;
|
||||
|
|
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