diff --git a/src/App.css b/src/App.css index c5862ea..8ebca88 100644 --- a/src/App.css +++ b/src/App.css @@ -40,25 +40,31 @@ .content { position: absolute; - width: 10000px; - /* Match VIRTUAL_CANVAS_SIZE */ - height: 10000px; + width: calc(100vw / 0.01); /* Width based on minimum zoom (0.01) */ + height: calc(100vh / 0.01); /* Height based on minimum zoom (0.01) */ transform-origin: 0 0; background-color: white; + will-change: transform; + contain: layout paint; } .target { position: absolute; - background-color: #888888; + background-color: #444444; /* Darker gray for better visibility */ border-radius: 50%; transition: background-color 0.2s ease; - box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); + box-shadow: 0 0 15px rgba(0, 0, 0, 0.3); transform: translate(-50%, -50%); + will-change: transform; + contain: layout; + border: 4px solid rgba(0, 0, 0, 0.4); /* Darker border for better contrast */ } .target.active { - background-color: #44aa44; - box-shadow: 0 0 20px rgba(68, 170, 68, 0.4); + background-color: #22aa22; /* Darker green for better visibility */ + box-shadow: 0 0 30px rgba(34, 170, 34, 0.8); + border: 4px solid white; + z-index: 10; } html, @@ -271,4 +277,89 @@ body, to { opacity: 1; } +} + +/* Preset Indicator Styles */ +.preset-indicator { + position: fixed; + top: 50%; + right: 30px; + transform: translateY(-50%); + z-index: 1000; + font-size: 24px; + font-weight: bold; + font-family: Inter, system-ui, Arial, sans-serif; + padding: 20px; + border-radius: 12px; + color: white; + text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2); + text-align: center; + transition: background-color 0.3s ease; +} + +.preset-indicator.preset-A { + background-color: #4285f4; /* Blue for Preset A */ +} + +.preset-indicator.preset-B { + background-color: #34a853; /* Green for Preset B */ +} + +.preset-indicator.preset-C { + background-color: #ea4335; /* Red for Preset C */ +} + +.preset-indicator.preset-D { + background-color: #fbbc05; /* Yellow for Preset D */ +} + +/* Preset Transition Effect */ +.preset-transition-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 1500; + pointer-events: none; + display: flex; + justify-content: center; + align-items: center; + animation: fadeInOut 1.5s ease-in-out; + opacity: 0; +} + +.preset-transition-content { + font-size: 36px; + font-weight: bold; + color: white; + text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); + padding: 30px 60px; + border-radius: 16px; + text-align: center; + transition: background-color 0.3s ease; +} + +.preset-transition-overlay.preset-A .preset-transition-content { + background-color: rgba(66, 133, 244, 0.85); /* Blue for Preset A */ +} + +.preset-transition-overlay.preset-B .preset-transition-content { + background-color: rgba(52, 168, 83, 0.85); /* Green for Preset B */ +} + +.preset-transition-overlay.preset-C .preset-transition-content { + background-color: rgba(234, 67, 53, 0.85); /* Red for Preset C */ +} + +.preset-transition-overlay.preset-D .preset-transition-content { + background-color: rgba(251, 188, 5, 0.85); /* Yellow for Preset D */ +} + +@keyframes fadeInOut { + 0% { opacity: 0; } + 30% { opacity: 1; } + 70% { opacity: 1; } + 100% { opacity: 0; } } \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 0d28687..dea5109 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { Settings } from './components/Settings'; import { RoundsHistory } from './components/RoundsHistory'; import { Instructions } from './components/Instructions'; @@ -65,7 +65,7 @@ function App() { y: 0, }); - // Interaction tracking refs + // Refs for interaction tracking const isDragging = useRef(false); const dragStartTime = useRef(0); const dragStartPos = useRef({ x: 0, y: 0 }); @@ -75,6 +75,11 @@ function App() { const zoomSpeedSamples = useRef([]); const touchStartDistance = useRef(0); const wasReset = useRef(false); + const resizeTimerRef = useRef(undefined); + + // Add state for tracking preset transitions + const [showPresetTransition, setShowPresetTransition] = useState(false); + const [transitionPreset, setTransitionPreset] = useState(null); // Load saved state on initial mount useEffect(() => { @@ -104,7 +109,8 @@ function App() { setTargets(newTargets); setCurrentTarget(getRandomTarget(null, CONSTANTS.TARGET_COUNT)); - const initialZoom = 0.2; + // Initialize at minimum zoom to show all targets + const initialZoom = CONSTANTS.ZOOM_LEVELS.min; setZoomLevel(initialZoom); setViewportOffset(calculateInitialViewport(initialZoom)); setStats(createInitialStats()); @@ -126,7 +132,7 @@ function App() { } }, [currentPreset]); - // Progress through presets in study mode + // Modify the useEffect for preset progression to show the transition useEffect(() => { if (mode !== 'study') return; @@ -139,9 +145,17 @@ function App() { const currentIndex = allPresets.indexOf(currentPreset); if (currentIndex < allPresets.length - 1) { + // Show the transition to the next preset const nextPreset = allPresets[currentIndex + 1]; - setCurrentPreset(nextPreset); - setTargetCount(0); + setTransitionPreset(nextPreset); + setShowPresetTransition(true); + + // Hide the transition after animation completes + setTimeout(() => { + setShowPresetTransition(false); + setCurrentPreset(nextPreset); + setTargetCount(0); + }, 1500); // Match the animation duration } else { setStudyComplete(true); } @@ -402,32 +416,65 @@ function App() { // Regenerate targets when window is resized useEffect(() => { - let resizeTimer: number; + let lastWidth = window.innerWidth; + let lastHeight = window.innerHeight; const handleResize = () => { if (mode !== 'study') { - clearTimeout(resizeTimer); + // Clear any existing timer + if (resizeTimerRef.current) { + window.clearTimeout(resizeTimerRef.current); + } - resizeTimer = window.setTimeout(() => { - const newTargets = generateTargets(); - setTargets(newTargets); - - if (currentTarget !== null) { - setCurrentTarget(getRandomTarget(currentTarget, CONSTANTS.TARGET_COUNT)); - } - - setShowResizeWarning(true); - }, 500); // Delay to detect when resizing is complete + // Only regenerate if the size changed significantly + const widthDiff = Math.abs(window.innerWidth - lastWidth); + const heightDiff = Math.abs(window.innerHeight - lastHeight); + const significantChange = widthDiff > 50 || heightDiff > 50; + + if (significantChange) { + resizeTimerRef.current = window.setTimeout(() => { + lastWidth = window.innerWidth; + lastHeight = window.innerHeight; + + const newTargets = generateTargets(); + setTargets(newTargets); + + if (currentTarget !== null) { + setCurrentTarget(getRandomTarget(currentTarget, CONSTANTS.TARGET_COUNT)); + } + + setShowResizeWarning(true); + }, 800); // Longer debounce to prevent excessive regeneration + } } }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); - clearTimeout(resizeTimer); + if (resizeTimerRef.current) { + window.clearTimeout(resizeTimerRef.current); + } }; }, [currentTarget, mode]); + // Simplified target rendering that doesn't filter by viewport to ensure all targets are visible + const renderedTargets = useMemo(() => { + return targets.map((target, index) => ( +
+ )); + }, [targets, currentTarget]); + return (
@@ -448,6 +495,24 @@ function App() { )}
+ {/* Add persistent preset indicator when in study mode */} + {mode === 'study' && ( +
+
Preset
+
{currentPreset}
+
{targetCount}/{CONSTANTS.STUDY_TARGETS_PER_PRESET}
+
+ )} + + {/* Add preset transition overlay */} + {showPresetTransition && transitionPreset && ( +
+
+ Switching to Preset {transitionPreset} +
+
+ )} +
- {targets.map((target, index) => ( -
- ))} + {renderedTargets}
diff --git a/src/types.ts b/src/types.ts index 1252d07..33a1694 100644 --- a/src/types.ts +++ b/src/types.ts @@ -43,21 +43,21 @@ export interface Round { export const CONSTANTS = { TARGET_COUNT: 200, - TARGET_MIN_RADIUS: 100, - TARGET_MAX_RADIUS: 800, + TARGET_MIN_RADIUS: 20, + TARGET_MAX_RADIUS: 40, VIRTUAL_CANVAS_SIZE: 50000, ZOOM_LEVELS: { min: 0.01, max: 10, - default: 0.2, + default: 0.01, }, MOUSE_WHEEL_SAMPLES: 5, NUM_CURVE_POINTS: 5, STUDY_TARGETS_PER_PRESET: 25, PRESETS: { - A: [1, 1, 1, 1, 1], // Control: no acceleration - B: [1, 2, 3, 4, 5], // Linear increase - C: [1, 1.5, 2.5, 4, 6], // Slow start, fast end + A: [1, 2, 3, 4, 5], // Linear increase + B: [1, 1.5, 2.5, 4, 6], // Slow start, fast end + C: [3, 2.5, 2, 1.5, 1], // Inverse/decreasing D: [1, 3, 4, 3, 1], // Bell curve } as Record, } as const; diff --git a/src/utils/interaction.ts b/src/utils/interaction.ts index 160d5bf..07d1af7 100644 --- a/src/utils/interaction.ts +++ b/src/utils/interaction.ts @@ -8,10 +8,22 @@ export const calculateZoomDelta = ( 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 calculateInitialViewport = (initialZoom: number): ViewportOffset => { + // When fully zoomed out, we want to see the entire content area + // The target distribution is sized to match the viewport at minimum zoom + // So at any higher zoom level, we need to position appropriately + const windowWidth = window.innerWidth; + const windowHeight = window.innerHeight; + + // Center the viewport on the target area + const areaWidth = windowWidth / CONSTANTS.ZOOM_LEVELS.min; + const areaHeight = windowHeight / CONSTANTS.ZOOM_LEVELS.min; + + return { + x: (areaWidth - windowWidth / initialZoom) / 2, + y: (areaHeight - windowHeight / initialZoom) / 2 + }; +}; export const clampZoom = (zoom: number): number => clamp(zoom, CONSTANTS.ZOOM_LEVELS.min, CONSTANTS.ZOOM_LEVELS.max); diff --git a/src/utils/target.ts b/src/utils/target.ts index c410038..236076c 100644 --- a/src/utils/target.ts +++ b/src/utils/target.ts @@ -2,59 +2,50 @@ import { Target, CONSTANTS } from '../types'; export const generateTargets = () => { const newTargets: Target[] = []; - const PADDING = CONSTANTS.TARGET_MAX_RADIUS * 2; - - // Get browser window aspect ratio + + // Fixed target size for better visibility + const MIN_RADIUS = 100; + const MAX_RADIUS = 600; + + // Get browser window dimensions const windowWidth = window.innerWidth; const windowHeight = window.innerHeight; - const aspectRatio = windowWidth / windowHeight; - // Calculate grid dimensions based on aspect ratio - const gridWidth = CONSTANTS.VIRTUAL_CANVAS_SIZE; - const gridHeight = gridWidth / aspectRatio; + // Base the target area on the minimum zoom level + // This ensures when fully zoomed out, we see the entire grid + const minZoom = CONSTANTS.ZOOM_LEVELS.min; + const areaWidth = windowWidth / minZoom; + const areaHeight = windowHeight / minZoom; - // Calculate cells based on aspect ratio - const totalCells = CONSTANTS.TARGET_COUNT; - const numColumns = Math.ceil(Math.sqrt(totalCells * aspectRatio)); - const numRows = Math.ceil(totalCells / numColumns); + // Create a grid-like distribution + const gridSize = Math.ceil(Math.sqrt(CONSTANTS.TARGET_COUNT)); + const cellWidth = areaWidth / gridSize; + const cellHeight = areaHeight / gridSize; - const cellWidth = (gridWidth - PADDING * 2) / numColumns; - const cellHeight = (gridHeight - PADDING * 2) / numRows; - 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 * cellWidth; - const baseY = PADDING + row * cellHeight; - - // Add some randomness within the cell - const x = baseX + Math.random() * (cellWidth - radius * 2); - const y = baseY + Math.random() * (cellHeight - 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) * 1.5) { - validPosition = false; - break; - } - } - } - newTargets.push(newTarget!); + // Calculate grid position + const gridX = i % gridSize; + const gridY = Math.floor(i / gridSize); + + // Add some randomness within each grid cell + const randomOffsetX = (Math.random() - 0.5) * cellWidth * 0.6; + const randomOffsetY = (Math.random() - 0.5) * cellHeight * 0.6; + + // Calculate final position + const x = cellWidth * (gridX + 0.5) + randomOffsetX; + const y = cellHeight * (gridY + 0.5) + randomOffsetY; + + // Random radius + const radius = MIN_RADIUS + Math.random() * (MAX_RADIUS - MIN_RADIUS); + + // Add the target to the array + newTargets.push({ + x, + y, + radius + }); } + return newTargets; };