diff --git a/apps/web/src/app/levels/page.tsx b/apps/web/src/app/levels/page.tsx index 0a56ae60..ab6e6a22 100644 --- a/apps/web/src/app/levels/page.tsx +++ b/apps/web/src/app/levels/page.tsx @@ -1,388 +1,11 @@ 'use client' -import { useState, useEffect } from 'react' -import { useSpring, useTransition, animated } from '@react-spring/web' -import * as Slider from '@radix-ui/react-slider' -import { AbacusReact, StandaloneBead } from '@soroban/abacus-react' import { PageWithNav } from '@/components/PageWithNav' +import { LevelSliderDisplay } from '@/components/LevelSliderDisplay' import { css } from '../../../styled-system/css' import { container, stack } from '../../../styled-system/patterns' -import { kyuLevelDetails } from '@/data/kyuLevelDetails' - -// Combine all levels into one array for the slider -const allLevels = [ - { - level: '10th Kyu', - emoji: '๐Ÿง’', - color: 'green', - digits: 2, - type: 'kyu' as const, - }, - { - level: '9th Kyu', - emoji: '๐Ÿง’', - color: 'green', - digits: 2, - type: 'kyu' as const, - }, - { - level: '8th Kyu', - emoji: '๐Ÿง’', - color: 'green', - digits: 3, - type: 'kyu' as const, - }, - { - level: '7th Kyu', - emoji: '๐Ÿง’', - color: 'green', - digits: 4, - type: 'kyu' as const, - }, - { - level: '6th Kyu', - emoji: '๐Ÿง‘', - color: 'blue', - digits: 5, - type: 'kyu' as const, - }, - { - level: '5th Kyu', - emoji: '๐Ÿง‘', - color: 'blue', - digits: 6, - type: 'kyu' as const, - }, - { - level: '4th Kyu', - emoji: '๐Ÿง‘', - color: 'blue', - digits: 7, - type: 'kyu' as const, - }, - { - level: '3rd Kyu', - emoji: '๐Ÿง”', - color: 'violet', - digits: 8, - type: 'kyu' as const, - }, - { - level: '2nd Kyu', - emoji: '๐Ÿง”', - color: 'violet', - digits: 9, - type: 'kyu' as const, - }, - { - level: '1st Kyu', - emoji: '๐Ÿง”', - color: 'violet', - digits: 10, - type: 'kyu' as const, - }, - { - level: 'Pre-1st Dan', - name: 'Jun-Shodan', - minScore: 90, - emoji: '๐Ÿง™', - color: 'amber', - digits: 30, - type: 'dan' as const, - }, - { - level: '1st Dan', - name: 'Shodan', - minScore: 100, - emoji: '๐Ÿง™', - color: 'amber', - digits: 30, - type: 'dan' as const, - }, - { - level: '2nd Dan', - name: 'Nidan', - minScore: 120, - emoji: '๐Ÿง™โ€โ™‚๏ธ', - color: 'amber', - digits: 30, - type: 'dan' as const, - }, - { - level: '3rd Dan', - name: 'Sandan', - minScore: 140, - emoji: '๐Ÿง™โ€โ™‚๏ธ', - color: 'amber', - digits: 30, - type: 'dan' as const, - }, - { - level: '4th Dan', - name: 'Yondan', - minScore: 160, - emoji: '๐Ÿง™โ€โ™€๏ธ', - color: 'amber', - digits: 30, - type: 'dan' as const, - }, - { - level: '5th Dan', - name: 'Godan', - minScore: 180, - emoji: '๐Ÿง™โ€โ™€๏ธ', - color: 'amber', - digits: 30, - type: 'dan' as const, - }, - { - level: '6th Dan', - name: 'Rokudan', - minScore: 200, - emoji: '๐Ÿง', - color: 'amber', - digits: 30, - type: 'dan' as const, - }, - { - level: '7th Dan', - name: 'Nanadan', - minScore: 220, - emoji: '๐Ÿง', - color: 'amber', - digits: 30, - type: 'dan' as const, - }, - { - level: '8th Dan', - name: 'Hachidan', - minScore: 250, - emoji: '๐Ÿงโ€โ™‚๏ธ', - color: 'amber', - digits: 30, - type: 'dan' as const, - }, - { - level: '9th Dan', - name: 'Kudan', - minScore: 270, - emoji: '๐Ÿงโ€โ™€๏ธ', - color: 'amber', - digits: 30, - type: 'dan' as const, - }, - { - level: '10th Dan', - name: 'Judan', - minScore: 290, - emoji: '๐Ÿ‘‘', - color: 'amber', - digits: 30, - type: 'dan' as const, - }, -] as const - -// Helper function to map level names to kyuLevelDetails keys -function getLevelDetailsKey(levelName: string): string | null { - // Convert "10th Kyu" โ†’ "10-kyu", "3rd Kyu" โ†’ "3-kyu", etc. - const match = levelName.match(/^(\d+)(?:st|nd|rd|th)\s+Kyu$/) - if (match) { - return `${match[1]}-kyu` - } - return null -} - -// Parse and format kyu level details into structured sections with icons -function parseKyuDetails(rawText: string) { - const lines = rawText.split('\n').filter((line) => line.trim() && !line.includes('shuzan.jp')) - - // Always return sections in consistent order: Add/Sub, Multiply, Divide - const sections: Array<{ - type: 'addSub' | 'multiply' | 'divide' - icon: string - label: string - digits: string | null - rows: string | null - chars: string | null - problems: string | null - }> = [ - { - type: 'addSub', - icon: 'โž•โž–', - label: 'Add/Sub', - digits: null, - rows: null, - chars: null, - problems: null, - }, - { - type: 'multiply', - icon: 'โœ–๏ธ', - label: 'Multiply', - digits: null, - rows: null, - chars: null, - problems: null, - }, - { - type: 'divide', - icon: 'โž—', - label: 'Divide', - digits: null, - rows: null, - chars: null, - problems: null, - }, - ] - - for (const line of lines) { - if (line.includes('Add/Sub:')) { - const match = line.match(/(\d+)-digit.*?(\d+)ๅฃ.*?(\d+)ๅญ—/) - if (match) { - sections[0].digits = match[1] - sections[0].rows = match[2] - sections[0].chars = match[3] - } - } else if (line.includes('ร—:')) { - const match = line.match(/(\d+) digits.*?\((\d+)/) - if (match) { - sections[1].digits = match[1] - sections[1].problems = match[2] - } - } else if (line.includes('รท:')) { - const match = line.match(/(\d+) digits.*?\((\d+)/) - if (match) { - sections[2].digits = match[1] - sections[2].problems = match[2] - } - } - } - - return sections -} export default function LevelsPage() { - const [currentIndex, setCurrentIndex] = useState(0) - const [isHovering, setIsHovering] = useState(false) - const [isPaneHovered, setIsPaneHovered] = useState(false) - const currentLevel = allLevels[currentIndex] - - // State for animated abacus digits - const [animatedDigits, setAnimatedDigits] = useState('') - - // Initialize animated digits when level changes - useEffect(() => { - const generateRandomDigits = (numDigits: number) => { - return Array.from({ length: numDigits }, () => Math.floor(Math.random() * 10)).join('') - } - setAnimatedDigits(generateRandomDigits(currentLevel.digits)) - }, [currentLevel.digits]) - - // Animate abacus calculations - speed increases with Dan level - useEffect(() => { - // Calculate animation speed based on level - // Kyu levels: 500ms - // Pre-1st Dan: 500ms - // 1st-10th Dan: interpolate from 500ms to 10ms - const getAnimationInterval = () => { - if (currentIndex < 11) { - // Kyu levels and Pre-1st Dan: constant 500ms - return 500 - } - // 1st Dan through 10th Dan: speed up from 500ms to 10ms - // Index 11 (1st Dan) โ†’ 500ms - // Index 20 (10th Dan) โ†’ 10ms - const danProgress = (currentIndex - 11) / 9 // 0.0 to 1.0 - return 500 - danProgress * 490 // 500ms down to 10ms - } - - const intervalMs = getAnimationInterval() - - const interval = setInterval(() => { - setAnimatedDigits((prev) => { - const digits = prev.split('').map(Number) - const numColumns = digits.length - - // Pick 1-3 adjacent columns to change (grouping effect) - const groupSize = Math.floor(Math.random() * 3) + 1 - const startCol = Math.floor(Math.random() * (numColumns - groupSize + 1)) - - // Change the selected columns - for (let i = startCol; i < startCol + groupSize && i < numColumns; i++) { - digits[i] = Math.floor(Math.random() * 10) - } - - return digits.join('') - }) - }, intervalMs) - - return () => clearInterval(interval) - }, [currentIndex]) - - // Auto-advance slider position every 3 seconds (unless pane is hovered) - useEffect(() => { - if (isPaneHovered) return // Don't auto-advance when mouse is over the pane - - const interval = setInterval(() => { - setCurrentIndex((prev) => { - // Cycle back to 0 when reaching the end - return prev >= allLevels.length - 1 ? 0 : prev + 1 - }) - }, 3000) - - return () => clearInterval(interval) - }, [isPaneHovered]) - - // Handle hover on slider track - const handleSliderHover = (e: React.MouseEvent) => { - const rect = e.currentTarget.getBoundingClientRect() - const x = e.clientX - rect.left - const percentage = x / rect.width - const index = Math.round(percentage * (allLevels.length - 1)) - setCurrentIndex(Math.max(0, Math.min(allLevels.length - 1, index))) - } - - // Calculate scale factor based on number of columns to fit the page - // Use constrained range to prevent huge size differences between levels - // Min 1.2 (for 30-column Dan levels) to Max 2.0 (for 2-column Kyu levels) - const scaleFactor = Math.max(1.2, Math.min(2.0, 20 / currentLevel.digits)) - - // Animate scale factor with React Spring for smooth transitions - const animatedProps = useSpring({ - scaleFactor, - config: { tension: 350, friction: 45 }, - }) - - // Animate emoji with proper cross-fade (old fades out, new fades in) - const emojiTransitions = useTransition(currentLevel.emoji, { - keys: currentIndex, - from: { opacity: 0 }, - enter: { opacity: 1 }, - leave: { opacity: 0 }, - config: { duration: 120 }, - }) - - // Convert animated digits to a number/BigInt for the abacus display - // Use BigInt for large numbers to get full 30-digit precision - const displayValue = - animatedDigits.length > 15 - ? BigInt(animatedDigits || '0') - : Number.parseInt(animatedDigits || '0', 10) - - // Dark theme styles matching the homepage - const darkStyles = { - columnPosts: { - fill: 'rgba(255, 255, 255, 0.3)', - stroke: 'rgba(255, 255, 255, 0.2)', - strokeWidth: 2, - }, - reckoningBar: { - fill: 'rgba(255, 255, 255, 0.4)', - stroke: 'rgba(255, 255, 255, 0.25)', - strokeWidth: 3, - }, - } - return (
@@ -449,471 +72,7 @@ export default function LevelsPage() { {/* Main content */}
- {/* Current Level Display */} -
setIsPaneHovered(true)} - onMouseLeave={() => setIsPaneHovered(false)} - className={css({ - bg: 'transparent', - border: '2px solid', - borderColor: - currentLevel.color === 'green' - ? 'green.500' - : currentLevel.color === 'blue' - ? 'blue.500' - : currentLevel.color === 'violet' - ? 'violet.500' - : 'amber.500', - rounded: 'xl', - p: { base: '6', md: '8' }, - height: { base: 'auto', md: '700px' }, - display: 'flex', - flexDirection: 'column', - })} - > - {/* Abacus-themed Radix Slider */} -
-
- {/* Emoji tick marks */} -
-
- {allLevels.map((level, index) => ( -
setCurrentIndex(index)} - className={css({ - fontSize: '4xl', - opacity: index === currentIndex ? '1' : '0.3', - transition: 'all 0.2s', - cursor: 'pointer', - pointerEvents: 'auto', - _hover: { opacity: index === currentIndex ? '1' : '0.6' }, - })} - > - {level.emoji} -
- ))} -
-
- - setCurrentIndex(value)} - min={0} - max={allLevels.length - 1} - step={1} - onMouseMove={handleSliderHover} - onMouseEnter={() => setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - className={css({ - position: 'relative', - display: 'flex', - alignItems: 'center', - userSelect: 'none', - touchAction: 'none', - w: 'full', - h: '32', - cursor: 'pointer', - })} - > - - - - - -
- -
- {emojiTransitions((style, emoji) => ( - - {emoji} - - ))} - - {/* Level text as part of the bead display */} -
-

- {currentLevel.level} -

- {'name' in currentLevel && ( -
- {currentLevel.name} -
- )} - {'minScore' in currentLevel && ( -
- Min: {currentLevel.minScore}pts -
- )} -
-
-
-
- - {/* Level Markers */} -
- 10th Kyu - 1st Kyu - 10th Dan -
-
- - {/* Abacus Display with Level Details */} -
- {/* Level Details (only for Kyu levels) */} - {currentLevel.type === 'kyu' && - (() => { - const detailsKey = getLevelDetailsKey(currentLevel.level) - const rawText = detailsKey - ? kyuLevelDetails[detailsKey as keyof typeof kyuLevelDetails] - : null - const sections = rawText ? parseKyuDetails(rawText) : [] - - // Use consistent sizing across all levels - const sizing = { fontSize: 'md', gap: '3', iconSize: '4xl' } - - return sections.length > 0 ? ( -
- {sections.map((section, idx) => { - const hasData = section.digits !== null - const levelColor = - currentLevel.color === 'green' - ? 'green.300' - : currentLevel.color === 'blue' - ? 'blue.300' - : 'violet.300' - - return ( -
- - {section.icon} - - {hasData && section.digits && ( - <> -
- {section.digits} digits -
- {(section.rows || section.chars) && ( -
- {section.rows && `${section.rows} rows`} - {section.rows && section.chars && ' โ€ข '} - {section.chars && `${section.chars} chars`} -
- )} - {section.problems && ( -
- {section.problems} problems -
- )} - - )} -
- {section.label} -
-
- ) - })} -
- ) : null - })()} - - {/* Abacus (right-aligned for Kyu, centered for Dan) */} -
- `scale(${s / scaleFactor})`), - }} - > - - -
-
- - {/* Digit Count */} -
- Requires mastery of {currentLevel.digits}-digit calculations -
-
- - {/* Legend */} -
-
-
- - Beginner (10-7 Kyu) - -
-
-
- - Intermediate (6-4 Kyu) - -
-
-
- - Advanced (3-1 Kyu) - -
-
-
- - Master (Dan ranks) - -
-
+ {/* Info Section */}
('') - const currentLevel = allLevels[levelIndex] - - // Initialize animated digits when level changes - useEffect(() => { - const generateRandomDigits = (numDigits: number) => { - return Array.from({ length: numDigits }, () => Math.floor(Math.random() * 10)).join('') - } - setAnimatedDigits(generateRandomDigits(currentLevel.digits)) - }, [currentLevel.digits]) - - // Animate abacus calculations - useEffect(() => { - const interval = setInterval(() => { - setAnimatedDigits((prev) => { - const digits = prev.split('').map(Number) - const numColumns = digits.length - const groupSize = Math.floor(Math.random() * 3) + 1 - const startCol = Math.floor(Math.random() * (numColumns - groupSize + 1)) - - for (let i = startCol; i < startCol + groupSize && i < numColumns; i++) { - digits[i] = Math.floor(Math.random() * 10) - } - return digits.join('') - }) - }, 500) - return () => clearInterval(interval) - }, [levelIndex]) - - // Auto-advance slider every 3 seconds (unless pane is hovered) - useEffect(() => { - if (isPaneHovered) return - - const interval = setInterval(() => { - setLevelIndex((prev) => { - return prev >= allLevels.length - 1 ? 0 : prev + 1 - }) - }, 3000) - - return () => clearInterval(interval) - }, [isPaneHovered]) - - // Calculate scale factor and animate it - const scaleFactor = Math.max(1.2, Math.min(2.0, 20 / currentLevel.digits)) - const animatedProps = useSpring({ - scaleFactor, - config: { tension: 350, friction: 45 }, - }) - - // Animate emoji transitions - const emojiTransitions = useTransition(currentLevel.emoji, { - keys: levelIndex, - from: { opacity: 0 }, - enter: { opacity: 1 }, - leave: { opacity: 0 }, - config: { duration: 120 }, - }) - - // Convert animated digits to number for abacus - const displayValue = - animatedDigits.length > 15 - ? BigInt(animatedDigits || '0') - : Number.parseInt(animatedDigits || '0', 10) - - // Dark theme styles for abacus - const darkStyles = { - columnPosts: { - fill: 'rgba(255, 255, 255, 0.3)', - stroke: 'rgba(255, 255, 255, 0.2)', - strokeWidth: 2, - }, - reckoningBar: { - fill: 'rgba(255, 255, 255, 0.4)', - stroke: 'rgba(255, 255, 255, 0.25)', - strokeWidth: 3, - }, - } - // Create different tutorials for each skill level const skillTutorials = [ // Skill 0: Read and set numbers (0-9999) @@ -501,17 +407,7 @@ export default function HomePage() {

- +
{/* Flashcard Generator Section */} diff --git a/apps/web/src/components/LevelSliderDisplay.tsx b/apps/web/src/components/LevelSliderDisplay.tsx new file mode 100644 index 00000000..35a55823 --- /dev/null +++ b/apps/web/src/components/LevelSliderDisplay.tsx @@ -0,0 +1,862 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useSpring, useTransition, animated } from '@react-spring/web' +import * as Slider from '@radix-ui/react-slider' +import { AbacusReact, StandaloneBead } from '@soroban/abacus-react' +import { css } from '../../styled-system/css' +import { stack } from '../../styled-system/patterns' +import { kyuLevelDetails } from '@/data/kyuLevelDetails' + +// Combine all levels into one array for the slider +const allLevels = [ + { + level: '10th Kyu', + emoji: '๐Ÿง’', + color: 'green', + digits: 2, + type: 'kyu' as const, + }, + { + level: '9th Kyu', + emoji: '๐Ÿง’', + color: 'green', + digits: 2, + type: 'kyu' as const, + }, + { + level: '8th Kyu', + emoji: '๐Ÿง’', + color: 'green', + digits: 3, + type: 'kyu' as const, + }, + { + level: '7th Kyu', + emoji: '๐Ÿง’', + color: 'green', + digits: 4, + type: 'kyu' as const, + }, + { + level: '6th Kyu', + emoji: '๐Ÿง‘', + color: 'blue', + digits: 5, + type: 'kyu' as const, + }, + { + level: '5th Kyu', + emoji: '๐Ÿง‘', + color: 'blue', + digits: 6, + type: 'kyu' as const, + }, + { + level: '4th Kyu', + emoji: '๐Ÿง‘', + color: 'blue', + digits: 7, + type: 'kyu' as const, + }, + { + level: '3rd Kyu', + emoji: '๐Ÿง”', + color: 'violet', + digits: 8, + type: 'kyu' as const, + }, + { + level: '2nd Kyu', + emoji: '๐Ÿง”', + color: 'violet', + digits: 9, + type: 'kyu' as const, + }, + { + level: '1st Kyu', + emoji: '๐Ÿง”', + color: 'violet', + digits: 10, + type: 'kyu' as const, + }, + { + level: 'Pre-1st Dan', + name: 'Jun-Shodan', + minScore: 90, + emoji: '๐Ÿง™', + color: 'amber', + digits: 30, + type: 'dan' as const, + }, + { + level: '1st Dan', + name: 'Shodan', + minScore: 100, + emoji: '๐Ÿง™', + color: 'amber', + digits: 30, + type: 'dan' as const, + }, + { + level: '2nd Dan', + name: 'Nidan', + minScore: 120, + emoji: '๐Ÿง™โ€โ™‚๏ธ', + color: 'amber', + digits: 30, + type: 'dan' as const, + }, + { + level: '3rd Dan', + name: 'Sandan', + minScore: 140, + emoji: '๐Ÿง™โ€โ™‚๏ธ', + color: 'amber', + digits: 30, + type: 'dan' as const, + }, + { + level: '4th Dan', + name: 'Yondan', + minScore: 160, + emoji: '๐Ÿง™โ€โ™€๏ธ', + color: 'amber', + digits: 30, + type: 'dan' as const, + }, + { + level: '5th Dan', + name: 'Godan', + minScore: 180, + emoji: '๐Ÿง™โ€โ™€๏ธ', + color: 'amber', + digits: 30, + type: 'dan' as const, + }, + { + level: '6th Dan', + name: 'Rokudan', + minScore: 200, + emoji: '๐Ÿง', + color: 'amber', + digits: 30, + type: 'dan' as const, + }, + { + level: '7th Dan', + name: 'Nanadan', + minScore: 220, + emoji: '๐Ÿง', + color: 'amber', + digits: 30, + type: 'dan' as const, + }, + { + level: '8th Dan', + name: 'Hachidan', + minScore: 250, + emoji: '๐Ÿงโ€โ™‚๏ธ', + color: 'amber', + digits: 30, + type: 'dan' as const, + }, + { + level: '9th Dan', + name: 'Kudan', + minScore: 270, + emoji: '๐Ÿงโ€โ™€๏ธ', + color: 'amber', + digits: 30, + type: 'dan' as const, + }, + { + level: '10th Dan', + name: 'Judan', + minScore: 290, + emoji: '๐Ÿ‘‘', + color: 'amber', + digits: 30, + type: 'dan' as const, + }, +] as const + +// Helper function to map level names to kyuLevelDetails keys +function getLevelDetailsKey(levelName: string): string | null { + // Convert "10th Kyu" โ†’ "10-kyu", "3rd Kyu" โ†’ "3-kyu", etc. + const match = levelName.match(/^(\d+)(?:st|nd|rd|th)\s+Kyu$/) + if (match) { + return `${match[1]}-kyu` + } + return null +} + +// Parse and format kyu level details into structured sections with icons +function parseKyuDetails(rawText: string) { + const lines = rawText.split('\n').filter((line) => line.trim() && !line.includes('shuzan.jp')) + + // Always return sections in consistent order: Add/Sub, Multiply, Divide + const sections: Array<{ + type: 'addSub' | 'multiply' | 'divide' + icon: string + label: string + digits: string | null + rows: string | null + chars: string | null + problems: string | null + }> = [ + { + type: 'addSub', + icon: 'โž•โž–', + label: 'Add/Sub', + digits: null, + rows: null, + chars: null, + problems: null, + }, + { + type: 'multiply', + icon: 'โœ–๏ธ', + label: 'Multiply', + digits: null, + rows: null, + chars: null, + problems: null, + }, + { + type: 'divide', + icon: 'โž—', + label: 'Divide', + digits: null, + rows: null, + chars: null, + problems: null, + }, + ] + + for (const line of lines) { + if (line.includes('Add/Sub:')) { + const match = line.match(/(\d+)-digit.*?(\d+)ๅฃ.*?(\d+)ๅญ—/) + if (match) { + sections[0].digits = match[1] + sections[0].rows = match[2] + sections[0].chars = match[3] + } + } else if (line.includes('ร—:')) { + const match = line.match(/(\d+) digits.*?\((\d+)/) + if (match) { + sections[1].digits = match[1] + sections[1].problems = match[2] + } + } else if (line.includes('รท:')) { + const match = line.match(/(\d+) digits.*?\((\d+)/) + if (match) { + sections[2].digits = match[1] + sections[2].problems = match[2] + } + } + } + + return sections +} + +// Dark theme styles matching the homepage +const darkStyles = { + columnPosts: { + fill: 'rgba(255, 255, 255, 0.3)', + stroke: 'rgba(255, 255, 255, 0.2)', + strokeWidth: 2, + }, + reckoningBar: { + fill: 'rgba(255, 255, 255, 0.4)', + stroke: 'rgba(255, 255, 255, 0.25)', + strokeWidth: 3, + }, +} + +interface LevelSliderDisplayProps { + initialIndex?: number + autoAdvanceEnabled?: boolean + autoAdvanceInterval?: number + showLegend?: boolean +} + +export function LevelSliderDisplay({ + initialIndex = 0, + autoAdvanceEnabled = true, + autoAdvanceInterval = 3000, + showLegend = true, +}: LevelSliderDisplayProps) { + const [currentIndex, setCurrentIndex] = useState(initialIndex) + const [isHovering, setIsHovering] = useState(false) + const [isPaneHovered, setIsPaneHovered] = useState(false) + const currentLevel = allLevels[currentIndex] + + // State for animated abacus digits + const [animatedDigits, setAnimatedDigits] = useState('') + + // Initialize animated digits when level changes + useEffect(() => { + const generateRandomDigits = (numDigits: number) => { + return Array.from({ length: numDigits }, () => Math.floor(Math.random() * 10)).join('') + } + setAnimatedDigits(generateRandomDigits(currentLevel.digits)) + }, [currentLevel.digits]) + + // Animate abacus calculations - speed increases with Dan level + useEffect(() => { + // Calculate animation speed based on level + // Kyu levels: 500ms + // Pre-1st Dan: 500ms + // 1st-10th Dan: interpolate from 500ms to 10ms + const getAnimationInterval = () => { + if (currentIndex < 11) { + // Kyu levels and Pre-1st Dan: constant 500ms + return 500 + } + // 1st Dan through 10th Dan: speed up from 500ms to 10ms + // Index 11 (1st Dan) โ†’ 500ms + // Index 20 (10th Dan) โ†’ 10ms + const danProgress = (currentIndex - 11) / 9 // 0.0 to 1.0 + return 500 - danProgress * 490 // 500ms down to 10ms + } + + const intervalMs = getAnimationInterval() + + const interval = setInterval(() => { + setAnimatedDigits((prev) => { + const digits = prev.split('').map(Number) + const numColumns = digits.length + + // Pick 1-3 adjacent columns to change (grouping effect) + const groupSize = Math.floor(Math.random() * 3) + 1 + const startCol = Math.floor(Math.random() * (numColumns - groupSize + 1)) + + // Change the selected columns + for (let i = startCol; i < startCol + groupSize && i < numColumns; i++) { + digits[i] = Math.floor(Math.random() * 10) + } + + return digits.join('') + }) + }, intervalMs) + + return () => clearInterval(interval) + }, [currentIndex]) + + // Auto-advance slider position every 3 seconds (unless pane is hovered) + useEffect(() => { + if (!autoAdvanceEnabled || isPaneHovered) return + + const interval = setInterval(() => { + setCurrentIndex((prev) => { + // Cycle back to 0 when reaching the end + return prev >= allLevels.length - 1 ? 0 : prev + 1 + }) + }, autoAdvanceInterval) + + return () => clearInterval(interval) + }, [autoAdvanceEnabled, isPaneHovered, autoAdvanceInterval]) + + // Handle hover on slider track + const handleSliderHover = (e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect() + const x = e.clientX - rect.left + const percentage = x / rect.width + const index = Math.round(percentage * (allLevels.length - 1)) + setCurrentIndex(Math.max(0, Math.min(allLevels.length - 1, index))) + } + + // Calculate scale factor based on number of columns to fit the page + // Use constrained range to prevent huge size differences between levels + // Min 1.2 (for 30-column Dan levels) to Max 2.0 (for 2-column Kyu levels) + const scaleFactor = Math.max(1.2, Math.min(2.0, 20 / currentLevel.digits)) + + // Animate scale factor with React Spring for smooth transitions + const animatedProps = useSpring({ + scaleFactor, + config: { tension: 350, friction: 45 }, + }) + + // Animate emoji with proper cross-fade (old fades out, new fades in) + const emojiTransitions = useTransition(currentLevel.emoji, { + keys: currentIndex, + from: { opacity: 0 }, + enter: { opacity: 1 }, + leave: { opacity: 0 }, + config: { duration: 120 }, + }) + + // Convert animated digits to a number/BigInt for the abacus display + // Use BigInt for large numbers to get full 30-digit precision + const displayValue = + animatedDigits.length > 15 + ? BigInt(animatedDigits || '0') + : Number.parseInt(animatedDigits || '0', 10) + + return ( +
+ {/* Current Level Display */} +
setIsPaneHovered(true)} + onMouseLeave={() => setIsPaneHovered(false)} + className={css({ + bg: 'transparent', + border: '2px solid', + borderColor: + currentLevel.color === 'green' + ? 'green.500' + : currentLevel.color === 'blue' + ? 'blue.500' + : currentLevel.color === 'violet' + ? 'violet.500' + : 'amber.500', + rounded: 'xl', + p: { base: '6', md: '8' }, + height: { base: 'auto', md: '700px' }, + display: 'flex', + flexDirection: 'column', + })} + > + {/* Abacus-themed Radix Slider */} +
+
+ {/* Emoji tick marks */} +
+
+ {allLevels.map((level, index) => ( +
setCurrentIndex(index)} + className={css({ + fontSize: '4xl', + opacity: index === currentIndex ? '1' : '0.3', + transition: 'all 0.2s', + cursor: 'pointer', + pointerEvents: 'auto', + _hover: { opacity: index === currentIndex ? '1' : '0.6' }, + })} + > + {level.emoji} +
+ ))} +
+
+ + setCurrentIndex(value)} + min={0} + max={allLevels.length - 1} + step={1} + onMouseMove={handleSliderHover} + onMouseEnter={() => setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + className={css({ + position: 'relative', + display: 'flex', + alignItems: 'center', + userSelect: 'none', + touchAction: 'none', + w: 'full', + h: '32', + cursor: 'pointer', + })} + > + + + + + +
+ +
+ {emojiTransitions((style, emoji) => ( + + {emoji} + + ))} + + {/* Level text as part of the bead display */} +
+

+ {currentLevel.level} +

+ {'name' in currentLevel && ( +
+ {currentLevel.name} +
+ )} + {'minScore' in currentLevel && ( +
+ Min: {currentLevel.minScore}pts +
+ )} +
+
+
+
+ + {/* Level Markers */} +
+ 10th Kyu + 1st Kyu + 10th Dan +
+
+ + {/* Abacus Display with Level Details */} +
+ {/* Level Details (only for Kyu levels) */} + {currentLevel.type === 'kyu' && + (() => { + const detailsKey = getLevelDetailsKey(currentLevel.level) + const rawText = detailsKey + ? kyuLevelDetails[detailsKey as keyof typeof kyuLevelDetails] + : null + const sections = rawText ? parseKyuDetails(rawText) : [] + + // Use consistent sizing across all levels + const sizing = { fontSize: 'md', gap: '3', iconSize: '4xl' } + + return sections.length > 0 ? ( +
+ {sections.map((section, idx) => { + const hasData = section.digits !== null + const levelColor = + currentLevel.color === 'green' + ? 'green.300' + : currentLevel.color === 'blue' + ? 'blue.300' + : 'violet.300' + + return ( +
+ + {section.icon} + + {hasData && section.digits && ( + <> +
+ {section.digits} digits +
+ {(section.rows || section.chars) && ( +
+ {section.rows && `${section.rows} rows`} + {section.rows && section.chars && ' โ€ข '} + {section.chars && `${section.chars} chars`} +
+ )} + {section.problems && ( +
+ {section.problems} problems +
+ )} + + )} +
+ {section.label} +
+
+ ) + })} +
+ ) : null + })()} + + {/* Abacus (right-aligned for Kyu, centered for Dan) */} +
+ `scale(${s / scaleFactor})`), + }} + > + + +
+
+ + {/* Digit Count */} +
+ Requires mastery of {currentLevel.digits}-digit calculations +
+
+ + {/* Legend */} + {showLegend && ( +
+
+
+ Beginner (10-7 Kyu) +
+
+
+ + Intermediate (6-4 Kyu) + +
+
+
+ Advanced (3-1 Kyu) +
+
+
+ Master (Dan ranks) +
+
+ )} +
+ ) +} diff --git a/apps/web/src/components/LevelsSlider.tsx b/apps/web/src/components/LevelsSlider.tsx deleted file mode 100644 index 83ef4ddd..00000000 --- a/apps/web/src/components/LevelsSlider.tsx +++ /dev/null @@ -1,253 +0,0 @@ -'use client' - -import { animated } from '@react-spring/web' -import * as Slider from '@radix-ui/react-slider' -import { AbacusReact, StandaloneBead } from '@soroban/abacus-react' -import { css } from '../../styled-system/css' - -interface Level { - level: string - emoji: string - color: 'green' | 'blue' | 'violet' | 'amber' - digits: number -} - -interface LevelsSliderProps { - levels: readonly Level[] - currentIndex: number - onIndexChange: (index: number) => void - onPaneHoverChange: (isHovered: boolean) => void - emojiTransitions: any - displayValue: number | bigint - scaleFactor: number - animatedProps: any - darkStyles: any -} - -export function LevelsSlider({ - levels, - currentIndex, - onIndexChange, - onPaneHoverChange, - emojiTransitions, - displayValue, - scaleFactor, - animatedProps, - darkStyles, -}: LevelsSliderProps) { - const currentLevel = levels[currentIndex] - - return ( -
onPaneHoverChange(true)} - onMouseLeave={() => onPaneHoverChange(false)} - className={css({ - bg: 'rgba(0, 0, 0, 0.4)', - border: '2px solid', - borderColor: - currentLevel.color === 'green' - ? 'green.500' - : currentLevel.color === 'blue' - ? 'blue.500' - : currentLevel.color === 'violet' - ? 'violet.500' - : 'amber.500', - rounded: 'xl', - p: { base: '6', md: '8' }, - height: { base: 'auto', md: '500px' }, - display: 'flex', - flexDirection: 'column', - })} - > - {/* Slider */} -
-
- {/* Emoji tick marks */} -
-
- {levels.map((level, index) => ( -
onIndexChange(index)} - className={css({ - fontSize: '3xl', - opacity: index === currentIndex ? '1' : '0.3', - transition: 'all 0.2s', - cursor: 'pointer', - pointerEvents: 'auto', - _hover: { opacity: index === currentIndex ? '1' : '0.6' }, - })} - > - {level.emoji} -
- ))} -
-
- - onIndexChange(value)} - min={0} - max={levels.length - 1} - step={1} - className={css({ - position: 'relative', - display: 'flex', - alignItems: 'center', - userSelect: 'none', - touchAction: 'none', - w: 'full', - h: '32', - cursor: 'pointer', - })} - > - - - - - -
- -
- {emojiTransitions((style: any, emoji: string) => ( - - {emoji} - - ))} - -
-

- {currentLevel.level} -

-
-
-
-
-
- - {/* Abacus Display */} -
- `scale(${s / scaleFactor})`), - }} - > - - -
- - {/* Digit Count */} -
- Master {currentLevel.digits}-digit calculations -
-
- ) -}