feat(abacus-react): export StandaloneBead component wired to AbacusDisplayContext
feat(levels): use StandaloneBead for slider thumb and decorative tick beads fix(levels): make slider background transparent to prevent abacus clipping Created StandaloneBead component that integrates with the abacus style context, replacing hardcoded SVG diamonds with proper context-aware bead rendering. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,203 +1,280 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSpring, animated } from '@react-spring/web'
|
||||
import * as Slider from '@radix-ui/react-slider'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { container, stack } from '../../../styled-system/patterns'
|
||||
import { useState } from "react";
|
||||
import { useSpring, animated } from "@react-spring/web";
|
||||
import * as Slider from "@radix-ui/react-slider";
|
||||
import {
|
||||
AbacusReact,
|
||||
StandaloneBead,
|
||||
AbacusDisplayProvider,
|
||||
} from "@soroban/abacus-react";
|
||||
import { PageWithNav } from "@/components/PageWithNav";
|
||||
import { css } from "../../../styled-system/css";
|
||||
import { container, stack } from "../../../styled-system/patterns";
|
||||
|
||||
// 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',
|
||||
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',
|
||||
emoji: "🧙",
|
||||
color: "amber",
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
type: "dan" as const,
|
||||
},
|
||||
{
|
||||
level: '1st Dan',
|
||||
name: 'Shodan',
|
||||
level: "1st Dan",
|
||||
name: "Shodan",
|
||||
minScore: 100,
|
||||
emoji: '🧙',
|
||||
color: 'amber',
|
||||
emoji: "🧙",
|
||||
color: "amber",
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
type: "dan" as const,
|
||||
},
|
||||
{
|
||||
level: '2nd Dan',
|
||||
name: 'Nidan',
|
||||
level: "2nd Dan",
|
||||
name: "Nidan",
|
||||
minScore: 120,
|
||||
emoji: '🧙♂️',
|
||||
color: 'amber',
|
||||
emoji: "🧙♂️",
|
||||
color: "amber",
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
type: "dan" as const,
|
||||
},
|
||||
{
|
||||
level: '3rd Dan',
|
||||
name: 'Sandan',
|
||||
level: "3rd Dan",
|
||||
name: "Sandan",
|
||||
minScore: 140,
|
||||
emoji: '🧙♂️',
|
||||
color: 'amber',
|
||||
emoji: "🧙♂️",
|
||||
color: "amber",
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
type: "dan" as const,
|
||||
},
|
||||
{
|
||||
level: '4th Dan',
|
||||
name: 'Yondan',
|
||||
level: "4th Dan",
|
||||
name: "Yondan",
|
||||
minScore: 160,
|
||||
emoji: '🧙♀️',
|
||||
color: 'amber',
|
||||
emoji: "🧙♀️",
|
||||
color: "amber",
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
type: "dan" as const,
|
||||
},
|
||||
{
|
||||
level: '5th Dan',
|
||||
name: 'Godan',
|
||||
level: "5th Dan",
|
||||
name: "Godan",
|
||||
minScore: 180,
|
||||
emoji: '🧙♀️',
|
||||
color: 'amber',
|
||||
emoji: "🧙♀️",
|
||||
color: "amber",
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
type: "dan" as const,
|
||||
},
|
||||
{
|
||||
level: '6th Dan',
|
||||
name: 'Rokudan',
|
||||
level: "6th Dan",
|
||||
name: "Rokudan",
|
||||
minScore: 200,
|
||||
emoji: '🧝',
|
||||
color: 'amber',
|
||||
emoji: "🧝",
|
||||
color: "amber",
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
type: "dan" as const,
|
||||
},
|
||||
{
|
||||
level: '7th Dan',
|
||||
name: 'Nanadan',
|
||||
level: "7th Dan",
|
||||
name: "Nanadan",
|
||||
minScore: 220,
|
||||
emoji: '🧝',
|
||||
color: 'amber',
|
||||
emoji: "🧝",
|
||||
color: "amber",
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
type: "dan" as const,
|
||||
},
|
||||
{
|
||||
level: '8th Dan',
|
||||
name: 'Hachidan',
|
||||
level: "8th Dan",
|
||||
name: "Hachidan",
|
||||
minScore: 250,
|
||||
emoji: '🧝♂️',
|
||||
color: 'amber',
|
||||
emoji: "🧝♂️",
|
||||
color: "amber",
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
type: "dan" as const,
|
||||
},
|
||||
{
|
||||
level: '9th Dan',
|
||||
name: 'Kudan',
|
||||
level: "9th Dan",
|
||||
name: "Kudan",
|
||||
minScore: 270,
|
||||
emoji: '🧝♀️',
|
||||
color: 'amber',
|
||||
emoji: "🧝♀️",
|
||||
color: "amber",
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
type: "dan" as const,
|
||||
},
|
||||
{
|
||||
level: '10th Dan',
|
||||
name: 'Judan',
|
||||
level: "10th Dan",
|
||||
name: "Judan",
|
||||
minScore: 290,
|
||||
emoji: '👑',
|
||||
color: 'amber',
|
||||
emoji: "👑",
|
||||
color: "amber",
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
type: "dan" as const,
|
||||
},
|
||||
] as const
|
||||
] as const;
|
||||
|
||||
export default function LevelsPage() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const currentLevel = allLevels[currentIndex]
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const currentLevel = allLevels[currentIndex];
|
||||
|
||||
// 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))
|
||||
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: 280, friction: 60 },
|
||||
})
|
||||
});
|
||||
|
||||
// Generate an interesting non-zero number to display on the abacus
|
||||
// Use a suffix pattern so rightmost digits stay constant as columns increase
|
||||
// This prevents beads from shifting: ones always 9, tens always 8, etc.
|
||||
const digitPattern = '123456789'
|
||||
const digitPattern = "123456789";
|
||||
// Use BigInt for numbers > 15 digits (Dan levels with 30 columns)
|
||||
const repeatedPattern = digitPattern.repeat(Math.ceil(currentLevel.digits / digitPattern.length))
|
||||
const digitString = repeatedPattern.slice(-currentLevel.digits)
|
||||
const repeatedPattern = digitPattern.repeat(
|
||||
Math.ceil(currentLevel.digits / digitPattern.length),
|
||||
);
|
||||
const digitString = repeatedPattern.slice(-currentLevel.digits);
|
||||
|
||||
// Use BigInt for large numbers to get full 30-digit precision
|
||||
const displayValue =
|
||||
currentLevel.digits > 15 ? BigInt(digitString) : Number.parseInt(digitString, 10)
|
||||
currentLevel.digits > 15
|
||||
? BigInt(digitString)
|
||||
: Number.parseInt(digitString, 10);
|
||||
|
||||
// Dark theme styles matching the homepage
|
||||
const darkStyles = {
|
||||
columnPosts: {
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: 'rgba(255, 255, 255, 0.2)',
|
||||
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)',
|
||||
fill: "rgba(255, 255, 255, 0.4)",
|
||||
stroke: "rgba(255, 255, 255, 0.25)",
|
||||
strokeWidth: 3,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWithNav navTitle="Kyu & Dan Levels" navEmoji="📊">
|
||||
<div className={css({ bg: 'gray.900', minHeight: '100vh', pb: '12' })}>
|
||||
<div className={css({ bg: "gray.900", minHeight: "100vh", pb: "12" })}>
|
||||
{/* Hero Section */}
|
||||
<div
|
||||
className={css({
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(17, 24, 39, 1) 0%, rgba(124, 58, 237, 0.3) 50%, rgba(17, 24, 39, 1) 100%)',
|
||||
color: 'white',
|
||||
py: { base: '12', md: '16' },
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
"linear-gradient(135deg, rgba(17, 24, 39, 1) 0%, rgba(124, 58, 237, 0.3) 50%, rgba(17, 24, 39, 1) 100%)",
|
||||
color: "white",
|
||||
py: { base: "12", md: "16" },
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
opacity: 0.1,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
|
||||
backgroundSize: '40px 40px',
|
||||
"radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)",
|
||||
backgroundSize: "40px 40px",
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className={container({ maxW: '6xl', px: '4', position: 'relative' })}>
|
||||
<div className={css({ textAlign: 'center', maxW: '5xl', mx: 'auto' })}>
|
||||
<div
|
||||
className={container({
|
||||
maxW: "6xl",
|
||||
px: "4",
|
||||
position: "relative",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({ textAlign: "center", maxW: "5xl", mx: "auto" })}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '3xl', md: '5xl', lg: '6xl' },
|
||||
fontWeight: 'bold',
|
||||
mb: '4',
|
||||
lineHeight: 'tight',
|
||||
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
fontSize: { base: "3xl", md: "5xl", lg: "6xl" },
|
||||
fontWeight: "bold",
|
||||
mb: "4",
|
||||
lineHeight: "tight",
|
||||
background:
|
||||
"linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)",
|
||||
backgroundClip: "text",
|
||||
color: "transparent",
|
||||
})}
|
||||
>
|
||||
Understanding Kyu & Dan Levels
|
||||
@@ -205,12 +282,12 @@ export default function LevelsPage() {
|
||||
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'lg', md: 'xl' },
|
||||
color: 'gray.300',
|
||||
mb: '6',
|
||||
maxW: '3xl',
|
||||
mx: 'auto',
|
||||
lineHeight: '1.6',
|
||||
fontSize: { base: "lg", md: "xl" },
|
||||
color: "gray.300",
|
||||
mb: "6",
|
||||
maxW: "3xl",
|
||||
mx: "auto",
|
||||
lineHeight: "1.6",
|
||||
})}
|
||||
>
|
||||
Slide through the complete progression from beginner to master
|
||||
@@ -220,78 +297,86 @@ export default function LevelsPage() {
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className={container({ maxW: '6xl', px: '4', py: '12' })}>
|
||||
<section className={stack({ gap: '8' })}>
|
||||
<div className={container({ maxW: "6xl", px: "4", py: "12" })}>
|
||||
<section className={stack({ gap: "8" })}>
|
||||
{/* Current Level Display */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
border: '2px solid',
|
||||
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',
|
||||
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",
|
||||
})}
|
||||
>
|
||||
{/* Level Info */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
mb: '4',
|
||||
height: '160px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
textAlign: "center",
|
||||
mb: "4",
|
||||
height: "160px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '5xl', mb: '3' })}>{currentLevel.emoji}</div>
|
||||
<div className={css({ fontSize: "5xl", mb: "3" })}>
|
||||
{currentLevel.emoji}
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
fontSize: { base: "2xl", md: "3xl" },
|
||||
fontWeight: "bold",
|
||||
color:
|
||||
currentLevel.color === 'green'
|
||||
? 'green.400'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.400'
|
||||
: currentLevel.color === 'violet'
|
||||
? 'violet.400'
|
||||
: 'amber.400',
|
||||
mb: '2',
|
||||
currentLevel.color === "green"
|
||||
? "green.400"
|
||||
: currentLevel.color === "blue"
|
||||
? "blue.400"
|
||||
: currentLevel.color === "violet"
|
||||
? "violet.400"
|
||||
: "amber.400",
|
||||
mb: "2",
|
||||
})}
|
||||
>
|
||||
{currentLevel.level}
|
||||
</h2>
|
||||
{'name' in currentLevel && (
|
||||
<div className={css({ fontSize: 'md', color: 'gray.400', mb: '1' })}>
|
||||
{"name" in currentLevel && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: "md",
|
||||
color: "gray.400",
|
||||
mb: "1",
|
||||
})}
|
||||
>
|
||||
{currentLevel.name}
|
||||
</div>
|
||||
)}
|
||||
{'minScore' in currentLevel && (
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.500' })}>
|
||||
{"minScore" in currentLevel && (
|
||||
<div className={css({ fontSize: "sm", color: "gray.500" })}>
|
||||
Minimum Score: {currentLevel.minScore} points
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Abacus-themed Radix Slider */}
|
||||
<div className={css({ mb: '6', px: { base: '2', md: '8' } })}>
|
||||
<div className={css({ mb: '3', textAlign: 'center' })}>
|
||||
<p className={css({ fontSize: 'sm', color: 'gray.400' })}>
|
||||
<div className={css({ mb: "6", px: { base: "2", md: "8" } })}>
|
||||
<div className={css({ mb: "3", textAlign: "center" })}>
|
||||
<p className={css({ fontSize: "sm", color: "gray.400" })}>
|
||||
Drag or click the beads to explore all levels
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={css({ position: 'relative', py: '6' })}>
|
||||
<div className={css({ position: "relative", py: "6" })}>
|
||||
<Slider.Root
|
||||
value={[currentIndex]}
|
||||
onValueChange={([value]) => setCurrentIndex(value)}
|
||||
@@ -299,115 +384,106 @@ export default function LevelsPage() {
|
||||
max={allLevels.length - 1}
|
||||
step={1}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
w: 'full',
|
||||
h: '12',
|
||||
cursor: 'pointer',
|
||||
position: "relative",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
userSelect: "none",
|
||||
touchAction: "none",
|
||||
w: "full",
|
||||
h: "12",
|
||||
cursor: "pointer",
|
||||
})}
|
||||
>
|
||||
{/* Decorative diamond beads for all levels */}
|
||||
{allLevels.map((level, index) => {
|
||||
const isActive = index === currentIndex
|
||||
const isDan = 'color' in level && level.color === 'violet'
|
||||
const size = isActive ? 14 : 8
|
||||
const isActive = index === currentIndex;
|
||||
const isDan =
|
||||
"color" in level && level.color === "violet";
|
||||
const size = isActive ? 14 : 8;
|
||||
const beadColor = isActive
|
||||
? isDan
|
||||
? '#8b5cf6'
|
||||
: '#22c55e'
|
||||
? "#8b5cf6"
|
||||
: "#22c55e"
|
||||
: isDan
|
||||
? 'rgba(139, 92, 246, 0.4)'
|
||||
: 'rgba(34, 197, 94, 0.4)'
|
||||
? "rgba(139, 92, 246, 0.4)"
|
||||
: "rgba(34, 197, 94, 0.4)";
|
||||
|
||||
// Match Radix Slider thumb positioning
|
||||
// Radix positions thumb center at the value position, accounting for thumb width
|
||||
const percentage = (index / (allLevels.length - 1)) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: `${(index / (allLevels.length - 1)) * 100}%`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
transition: 'all 0.2s',
|
||||
pointerEvents: 'none',
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: `${percentage}%`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
transition: "all 0.2s",
|
||||
pointerEvents: "none",
|
||||
zIndex: 0,
|
||||
filter: isActive
|
||||
? `drop-shadow(0 0 6px ${isDan ? 'rgba(139, 92, 246, 0.6)' : 'rgba(34, 197, 94, 0.6)'})`
|
||||
: 'none',
|
||||
? `drop-shadow(0 0 6px ${isDan ? "rgba(139, 92, 246, 0.6)" : "rgba(34, 197, 94, 0.6)"})`
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
<polygon
|
||||
points={`0,${size / 2} ${size / 2},0 ${size},${size / 2} ${size / 2},${size}`}
|
||||
fill={beadColor}
|
||||
stroke={
|
||||
isActive
|
||||
? isDan
|
||||
? '#c4b5fd'
|
||||
: '#86efac'
|
||||
: 'rgba(255, 255, 255, 0.3)'
|
||||
}
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
</svg>
|
||||
<StandaloneBead
|
||||
size={size}
|
||||
color={beadColor}
|
||||
shape="diamond"
|
||||
animated={false}
|
||||
active={
|
||||
isActive ||
|
||||
(beadColor !== "rgba(139, 92, 246, 0.4)" &&
|
||||
beadColor !== "rgba(34, 197, 94, 0.4)")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
|
||||
<Slider.Track
|
||||
className={css({
|
||||
bg: 'rgba(255, 255, 255, 0.2)',
|
||||
position: 'relative',
|
||||
bg: "rgba(255, 255, 255, 0.2)",
|
||||
position: "relative",
|
||||
flexGrow: 1,
|
||||
rounded: 'full',
|
||||
h: '3px',
|
||||
rounded: "full",
|
||||
h: "3px",
|
||||
})}
|
||||
>
|
||||
<Slider.Range className={css({ display: 'none' })} />
|
||||
<Slider.Range className={css({ display: "none" })} />
|
||||
</Slider.Track>
|
||||
|
||||
<Slider.Thumb
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
w: '28px',
|
||||
h: '28px',
|
||||
bg: 'transparent',
|
||||
cursor: 'grab',
|
||||
transition: 'all 0.2s',
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
w: "28px",
|
||||
h: "28px",
|
||||
bg: "transparent",
|
||||
cursor: "grab",
|
||||
transition: "all 0.2s",
|
||||
zIndex: 10,
|
||||
_hover: { transform: 'scale(1.15)' },
|
||||
_hover: { transform: "scale(1.15)" },
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
transform: 'scale(1.15)',
|
||||
outline: "none",
|
||||
transform: "scale(1.15)",
|
||||
},
|
||||
_active: { cursor: 'grabbing' },
|
||||
_active: { cursor: "grabbing" },
|
||||
})}
|
||||
>
|
||||
<svg width="28" height="28" viewBox="0 0 28 28">
|
||||
{/* Diamond bead matching abacus style */}
|
||||
<polygon
|
||||
points="0,14 14,0 28,14 14,28"
|
||||
fill={currentLevel.color === 'violet' ? '#8b5cf6' : '#22c55e'}
|
||||
stroke="#000"
|
||||
strokeWidth="0.8"
|
||||
/>
|
||||
{/* Inner highlight for depth */}
|
||||
<polygon
|
||||
points="3,14 14,3 25,14 14,25"
|
||||
fill={
|
||||
currentLevel.color === 'violet'
|
||||
? 'rgba(139, 92, 246, 0.3)'
|
||||
: 'rgba(34, 197, 94, 0.3)'
|
||||
}
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
<StandaloneBead
|
||||
size={28}
|
||||
color={
|
||||
currentLevel.color === "violet"
|
||||
? "#8b5cf6"
|
||||
: "#22c55e"
|
||||
}
|
||||
shape="diamond"
|
||||
animated={false}
|
||||
/>
|
||||
</Slider.Thumb>
|
||||
</Slider.Root>
|
||||
</div>
|
||||
@@ -415,11 +491,11 @@ export default function LevelsPage() {
|
||||
{/* Level Markers */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
mt: '1',
|
||||
fontSize: 'xs',
|
||||
color: 'gray.500',
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
mt: "1",
|
||||
fontSize: "xs",
|
||||
color: "gray.500",
|
||||
})}
|
||||
>
|
||||
<span>10th Kyu</span>
|
||||
@@ -431,21 +507,23 @@ export default function LevelsPage() {
|
||||
{/* Abacus Display */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
overflow: 'hidden',
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
p: "6",
|
||||
bg: "rgba(0, 0, 0, 0.3)",
|
||||
rounded: "lg",
|
||||
border: "1px solid",
|
||||
borderColor: "gray.700",
|
||||
overflow: "hidden",
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<animated.div
|
||||
style={{
|
||||
transform: animatedProps.scaleFactor.to((s) => `scale(${s / scaleFactor})`),
|
||||
transform: animatedProps.scaleFactor.to(
|
||||
(s) => `scale(${s / scaleFactor})`,
|
||||
),
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
@@ -459,46 +537,105 @@ export default function LevelsPage() {
|
||||
</div>
|
||||
|
||||
{/* Digit Count */}
|
||||
<div className={css({ textAlign: 'center', color: 'gray.400', fontSize: 'sm' })}>
|
||||
Requires mastery of <strong>{currentLevel.digits}-digit</strong> calculations
|
||||
<div
|
||||
className={css({
|
||||
textAlign: "center",
|
||||
color: "gray.400",
|
||||
fontSize: "sm",
|
||||
})}
|
||||
>
|
||||
Requires mastery of <strong>{currentLevel.digits}-digit</strong>{" "}
|
||||
calculations
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6',
|
||||
justifyContent: 'center',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: "6",
|
||||
justifyContent: "center",
|
||||
p: "6",
|
||||
bg: "rgba(0, 0, 0, 0.3)",
|
||||
rounded: "lg",
|
||||
border: "1px solid",
|
||||
borderColor: "gray.700",
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'green.500', rounded: 'sm' })} />
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "2",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: "4",
|
||||
h: "4",
|
||||
bg: "green.500",
|
||||
rounded: "sm",
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: "sm", color: "gray.300" })}>
|
||||
Beginner (10-7 Kyu)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'blue.500', rounded: 'sm' })} />
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "2",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: "4",
|
||||
h: "4",
|
||||
bg: "blue.500",
|
||||
rounded: "sm",
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: "sm", color: "gray.300" })}>
|
||||
Intermediate (6-4 Kyu)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'violet.500', rounded: 'sm' })} />
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "2",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: "4",
|
||||
h: "4",
|
||||
bg: "violet.500",
|
||||
rounded: "sm",
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: "sm", color: "gray.300" })}>
|
||||
Advanced (3-1 Kyu)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'amber.500', rounded: 'sm' })} />
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
<div
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "2",
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: "4",
|
||||
h: "4",
|
||||
bg: "amber.500",
|
||||
rounded: "sm",
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: "sm", color: "gray.300" })}>
|
||||
Master (Dan ranks)
|
||||
</span>
|
||||
</div>
|
||||
@@ -507,34 +644,39 @@ export default function LevelsPage() {
|
||||
{/* Info Section */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
rounded: 'xl',
|
||||
p: { base: '6', md: '8' },
|
||||
bg: "rgba(0, 0, 0, 0.4)",
|
||||
border: "1px solid",
|
||||
borderColor: "gray.700",
|
||||
rounded: "xl",
|
||||
p: { base: "6", md: "8" },
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: { base: 'xl', md: '2xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '4',
|
||||
fontSize: { base: "xl", md: "2xl" },
|
||||
fontWeight: "bold",
|
||||
color: "white",
|
||||
mb: "4",
|
||||
})}
|
||||
>
|
||||
About This Ranking System
|
||||
</h3>
|
||||
<div className={stack({ gap: '4' })}>
|
||||
<p className={css({ color: 'gray.300', lineHeight: '1.6' })}>
|
||||
This ranking system is based on the official examination structure used by the{' '}
|
||||
<strong className={css({ color: 'white' })}>Japan Abacus Federation</strong>. It
|
||||
represents a standardized progression from beginner (10th Kyu) to master level
|
||||
(10th Dan), used internationally for soroban proficiency assessment.
|
||||
<div className={stack({ gap: "4" })}>
|
||||
<p className={css({ color: "gray.300", lineHeight: "1.6" })}>
|
||||
This ranking system is based on the official examination
|
||||
structure used by the{" "}
|
||||
<strong className={css({ color: "white" })}>
|
||||
Japan Abacus Federation
|
||||
</strong>
|
||||
. It represents a standardized progression from beginner (10th
|
||||
Kyu) to master level (10th Dan), used internationally for
|
||||
soroban proficiency assessment.
|
||||
</p>
|
||||
<p className={css({ color: 'gray.300', lineHeight: '1.6' })}>
|
||||
The system is designed to gradually increase in difficulty. Kyu levels progress
|
||||
from 2-digit calculations at 10th Kyu to 10-digit calculations at 1st Kyu. Dan
|
||||
levels all require mastery of 30-digit calculations, with ranks awarded based on
|
||||
<p className={css({ color: "gray.300", lineHeight: "1.6" })}>
|
||||
The system is designed to gradually increase in difficulty.
|
||||
Kyu levels progress from 2-digit calculations at 10th Kyu to
|
||||
10-digit calculations at 1st Kyu. Dan levels all require
|
||||
mastery of 30-digit calculations, with ranks awarded based on
|
||||
exam scores.
|
||||
</p>
|
||||
</div>
|
||||
@@ -543,5 +685,5 @@ export default function LevelsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
147
packages/abacus-react/src/StandaloneBead.tsx
Normal file
147
packages/abacus-react/src/StandaloneBead.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useSpring, animated } from "@react-spring/web";
|
||||
import {
|
||||
useAbacusConfig,
|
||||
getDefaultAbacusConfig,
|
||||
type BeadShape,
|
||||
} from "./AbacusContext";
|
||||
|
||||
export interface StandaloneBeadProps {
|
||||
/** Size of the bead in pixels */
|
||||
size?: number;
|
||||
/** Override the shape from context (diamond, circle, square) */
|
||||
shape?: BeadShape;
|
||||
/** Override the color from context */
|
||||
color?: string;
|
||||
/** Enable animation */
|
||||
animated?: boolean;
|
||||
/** Custom className */
|
||||
className?: string;
|
||||
/** Custom style */
|
||||
style?: React.CSSProperties;
|
||||
/** Active state for the bead */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone Bead component that respects the AbacusDisplayContext.
|
||||
* This component renders a single abacus bead with styling from the context.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* import { StandaloneBead, AbacusDisplayProvider } from '@soroban/abacus-react';
|
||||
*
|
||||
* <AbacusDisplayProvider>
|
||||
* <StandaloneBead size={28} color="#8b5cf6" />
|
||||
* </AbacusDisplayProvider>
|
||||
* ```
|
||||
*/
|
||||
export const StandaloneBead: React.FC<StandaloneBeadProps> = ({
|
||||
size = 28,
|
||||
shape: shapeProp,
|
||||
color: colorProp,
|
||||
animated: animatedProp,
|
||||
className,
|
||||
style,
|
||||
active = true,
|
||||
}) => {
|
||||
// Try to use context config, fallback to defaults if no context
|
||||
let contextConfig;
|
||||
try {
|
||||
contextConfig = useAbacusConfig();
|
||||
} catch {
|
||||
// No context provider, use defaults
|
||||
contextConfig = getDefaultAbacusConfig();
|
||||
}
|
||||
|
||||
// Use props if provided, otherwise fall back to context config
|
||||
const shape = shapeProp ?? contextConfig.beadShape;
|
||||
const enableAnimation = animatedProp ?? contextConfig.animated;
|
||||
const color = colorProp ?? "#000000";
|
||||
|
||||
const [springs, api] = useSpring(() => ({
|
||||
scale: 1,
|
||||
config: { tension: 300, friction: 20 },
|
||||
}));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enableAnimation) {
|
||||
api.start({ scale: 1 });
|
||||
}
|
||||
}, [enableAnimation, api]);
|
||||
|
||||
const renderShape = () => {
|
||||
const halfSize = size / 2;
|
||||
const actualColor = active ? color : "rgb(211, 211, 211)";
|
||||
|
||||
switch (shape) {
|
||||
case "diamond":
|
||||
return (
|
||||
<polygon
|
||||
points={`${size * 0.7},0 ${size * 1.4},${halfSize} ${size * 0.7},${size} 0,${halfSize}`}
|
||||
fill={actualColor}
|
||||
stroke="#000"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
);
|
||||
case "square":
|
||||
return (
|
||||
<rect
|
||||
width={size}
|
||||
height={size}
|
||||
fill={actualColor}
|
||||
stroke="#000"
|
||||
strokeWidth="0.5"
|
||||
rx="1"
|
||||
/>
|
||||
);
|
||||
case "circle":
|
||||
default:
|
||||
return (
|
||||
<circle
|
||||
cx={halfSize}
|
||||
cy={halfSize}
|
||||
r={halfSize}
|
||||
fill={actualColor}
|
||||
stroke="#000"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getXOffset = () => {
|
||||
return shape === "diamond" ? size * 0.7 : size / 2;
|
||||
};
|
||||
|
||||
const getYOffset = () => {
|
||||
return size / 2;
|
||||
};
|
||||
|
||||
const AnimatedG = animated.g;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size * (shape === "diamond" ? 1.4 : 1)}
|
||||
height={size}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<AnimatedG
|
||||
transform={`translate(0, 0)`}
|
||||
style={
|
||||
enableAnimation
|
||||
? {
|
||||
transform: springs.scale.to((s) => `scale(${s})`),
|
||||
transformOrigin: "center",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{renderShape()}
|
||||
</AnimatedG>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -14,3 +14,6 @@ export type {
|
||||
AbacusDisplayConfig,
|
||||
AbacusDisplayContextType,
|
||||
} from "./AbacusContext";
|
||||
|
||||
export { StandaloneBead } from "./StandaloneBead";
|
||||
export type { StandaloneBeadProps } from "./StandaloneBead";
|
||||
|
||||
Reference in New Issue
Block a user