feat: add interactive bead clicking to soroban abacus

- Implement click handlers for heaven and earth beads with data attributes
- Add proper abacus logic for bead activation/deactivation
- Earth beads follow traditional stacking behavior (0-3 position numbering)
- Heaven beads toggle 5-value contributions per column
- Add visual feedback with hover effects and cursor changes
- Update instructions to guide users on interactive functionality
- Support multi-column place value calculations

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-15 17:47:09 -05:00
parent a757f19b12
commit 697552ecd9
4 changed files with 231 additions and 463 deletions

View File

@@ -42,6 +42,112 @@ export function InteractiveAbacus({
// Handle bead clicks to toggle values
const handleBeadClick = useCallback((event: Event) => {
const target = event.target as Element
// Find the closest element with bead data attributes
const beadElement = target.closest('[data-bead-type]')
if (!beadElement) return
const beadType = beadElement.getAttribute('data-bead-type')
const beadColumn = parseInt(beadElement.getAttribute('data-bead-column') || '0')
const beadPosition = beadElement.getAttribute('data-bead-position')
const isActive = beadElement.getAttribute('data-bead-active') === '1'
console.log('Bead clicked:', { beadType, beadColumn, beadPosition, isActive })
console.log('Current value before click:', currentValue)
if (beadType === 'earth') {
const position = parseInt(beadPosition || '0')
const columnPower = Math.pow(10, beadColumn)
const currentDigit = Math.floor(currentValue / columnPower) % 10
const heavenContribution = Math.floor(currentDigit / 5) * 5
const earthContribution = currentDigit % 5
console.log('Earth bead analysis:', {
position,
columnPower,
currentDigit,
heavenContribution,
earthContribution
})
}
if (beadType === 'heaven') {
// Toggle heaven bead (worth 5)
const columnPower = Math.pow(10, beadColumn)
const heavenValue = 5 * columnPower
if (isActive) {
// Deactivate heaven bead - subtract 5 from this column
setCurrentValue(prev => Math.max(0, prev - heavenValue))
} else {
// Activate heaven bead - add 5 to this column
setCurrentValue(prev => prev + heavenValue)
}
} else if (beadType === 'earth' && beadPosition) {
// Toggle earth bead (worth 1 each)
const position = parseInt(beadPosition) // 0-3 where 0 is top (closest to bar), 3 is bottom
const columnPower = Math.pow(10, beadColumn)
// Calculate current digit in this column
const currentDigit = Math.floor(currentValue / columnPower) % 10
const heavenContribution = Math.floor(currentDigit / 5) * 5
const earthContribution = currentDigit % 5
let newEarthContribution: number
// Earth beads are numbered 0-3 from top to bottom (0 is closest to bar)
// In traditional abacus logic:
// - earthContribution represents how many beads are active (0-4)
// - Active beads are positions 0, 1, 2, ... up to (earthContribution - 1)
// - When you click a bead: toggle that "level" of activation
if (isActive) {
// This bead is currently active, so we deactivate it and all beads below it
// If position 2 is clicked and active, we want positions 0,1 to remain active
// So earthContribution should be position (2)
newEarthContribution = position
} else {
// This bead is currently inactive, so we activate it and all beads above it
// If position 2 is clicked and inactive, we want positions 0,1,2 to be active
// So earthContribution should be position + 1 (3)
newEarthContribution = position + 1
}
console.log('Earth bead calculation:', {
position,
isActive,
currentEarthContribution: earthContribution,
newEarthContribution
})
// Calculate the new digit for this column
const newDigit = heavenContribution + newEarthContribution
// Calculate the new total value
const columnContribution = Math.floor(currentValue / columnPower) % 10 * columnPower
const newValue = currentValue - columnContribution + (newDigit * columnPower)
setCurrentValue(Math.max(0, newValue))
}
// Visual feedback
setIsChanging(true)
setTimeout(() => setIsChanging(false), 150)
}, [currentValue])
// Add click event listener for bead interactions
useEffect(() => {
const svgContainer = svgRef.current
if (!svgContainer) return
svgContainer.addEventListener('click', handleBeadClick)
return () => {
svgContainer.removeEventListener('click', handleBeadClick)
}
}, [handleBeadClick])
// Notify parent of value changes
useMemo(() => {
onValueChange?.(currentValue)
@@ -101,7 +207,16 @@ export function InteractiveAbacus({
className={css({
width: '100%',
height: '100%',
transition: 'all 0.3s ease'
transition: 'all 0.3s ease',
cursor: 'pointer',
'& [data-bead-type]': {
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
filter: 'brightness(1.2)',
transform: 'scale(1.05)'
}
}
})}
/>
@@ -213,8 +328,9 @@ export function InteractiveAbacus({
border: '1px solid',
borderColor: 'gray.200'
})}>
<strong>How to use:</strong> Use the preset buttons below to set different values.
The abacus will display the number using traditional soroban bead positions.
<strong>How to use:</strong> Click on the beads to activate or deactivate them!
Heaven beads (top) are worth 5 each, earth beads (bottom) are worth 1 each.
You can also use the preset buttons below.
</div>
</div>
</div>

View File

@@ -855,18 +855,6 @@
}
@media (max-width: 768px) {
.\[\@media_\(max-width\:_768px\)\]\:gap_10px {
gap: 10px
}
}
@media (max-width: 480px) {
.\[\@media_\(max-width\:_480px\)\]\:gap_8px {
gap: 8px
}
}
@media (max-width: 768px) {
.\[\@media_\(max-width\:_768px\)\]\:h_130px {
height: 130px
}
@@ -899,18 +887,6 @@
min-width: 90px
}
}
@media (max-width: 768px) {
.\[\@media_\(max-width\:_768px\)\]\:fs_40px {
font-size: 40px
}
}
@media (max-width: 480px) {
.\[\@media_\(max-width\:_480px\)\]\:fs_32px {
font-size: 32px
}
}
@media screen and (min-width: 48em) {
.md\:px_4 {
@@ -933,6 +909,9 @@
}
@media (max-width: 768px) {
.\[\@media_\(max-width\:_768px\)\]\:gap_10px {
gap: 10px
}
.\[\@media_\(max-width\:_768px\)\]\:h_130px {
height: 130px
}
@@ -940,6 +919,9 @@
.\[\@media_\(max-width\:_768px\)\]\:min-w_100px {
min-width: 100px
}
.\[\@media_\(max-width\:_768px\)\]\:fs_40px {
font-size: 40px
}
.\[\@media_\(max-width\:_768px\)\]\:gap_10px {
gap: 10px
@@ -959,6 +941,9 @@
}
@media (max-width: 480px) {
.\[\@media_\(max-width\:_480px\)\]\:gap_8px {
gap: 8px
}
.\[\@media_\(max-width\:_480px\)\]\:h_120px {
height: 120px
}
@@ -966,6 +951,9 @@
.\[\@media_\(max-width\:_480px\)\]\:min-w_90px {
min-width: 90px
}
.\[\@media_\(max-width\:_480px\)\]\:fs_32px {
font-size: 32px
}
.\[\@media_\(max-width\:_480px\)\]\:gap_8px {
gap: 8px

View File

@@ -1,185 +1,5 @@
@layer utilities {
.border_blue\.600 {
border-color: var(--colors-blue-600)
}
.bg_blue\.25 {
background: blue.25
}
.w_6 {
width: var(--sizes-6)
}
.h_6 {
height: var(--sizes-6)
}
.border-t_blue\.500 {
border-top-color: var(--colors-blue-500)
}
.rounded_full {
border-radius: var(--radii-full)
}
.animation_spin_1s_linear_infinite {
animation: spin 1s linear infinite
}
.bg_red\.25 {
background: red.25
}
.rounded_md {
border-radius: var(--radii-md)
}
.min-h_300px {
min-height: 300px
}
.opacity_0\.6 {
opacity: 0.6
}
.w_full {
width: var(--sizes-full)
}
.h_full {
height: var(--sizes-full)
}
.overflow_hidden {
overflow: hidden
}
.bg_white {
background: var(--colors-white)
}
.\[\&_svg\]\:w_100\% svg {
width: 100%
}
.\[\&_svg\]\:h_100\% svg {
height: 100%
}
.\[\&_svg\]\:max-w_100\% svg {
max-width: 100%
}
.\[\&_svg\]\:max-h_100\% svg {
max-height: 100%
}
.\[\&_svg\]\:object_contain svg {
object-fit: contain
}
.\[\&_svg\]\:transition_all_0\.2s_ease svg {
transition: all 0.2s ease
}
.gap_4 {
gap: var(--spacing-4)
}
.mt_2 {
margin-top: var(--spacing-2)
}
.min-w_80px {
min-width: 80px
}
.max-w_300px {
max-width: 300px
}
.w_24px {
width: 24px
}
.h_8px {
height: 8px
}
.rounded_8px {
border-radius: 8px
}
.border_green\.600 {
border-color: var(--colors-green-600)
}
.border_gray\.400 {
border-color: var(--colors-gray-400)
}
.w_20px {
width: 20px
}
.h_6px {
height: 6px
}
.rounded_6px {
border-radius: 6px
}
.transition_border-color_0\.2s_ease {
transition: border-color 0.2s ease
}
.p_16px_8px {
padding: 16px 8px
}
.gap_8px {
gap: 8px
}
.min-h_60px {
min-height: 60px
}
.justify_flex-end {
justify-content: flex-end
}
.w_50px {
width: 50px
}
.h_4px {
height: 4px
}
.bg_gray\.800 {
background-color: var(--colors-gray-800)
}
.rounded_2px {
border-radius: 2px
}
.gap_4px {
gap: 4px
}
.min-h_80px {
min-height: 80px
}
.justify_flex-start {
justify-content: flex-start
}
.flex_column {
flex-direction: column
}
@@ -188,58 +8,6 @@
gap: var(--spacing-6)
}
.gap_2 {
gap: var(--spacing-2)
}
.border_amber\.800 {
border-color: var(--colors-amber-800)
}
.max-w_400px {
max-width: 400px
}
.border_\#d97706 {
border-color: #d97706
}
.pos_absolute {
position: absolute
}
.top_50\% {
top: 50%
}
.left_50\% {
left: 50%
}
.transform_translate\(-50\%\,_-50\%\) {
transform: translate(-50%, -50%)
}
.pointer-events_none {
pointer-events: none
}
.fs_2xl {
font-size: var(--font-sizes-2xl)
}
.text_orange\.600 {
color: var(--colors-orange-600)
}
.text-shadow_0_2px_4px_rgba\(0\,0\,0\,0\.2\) {
text-shadow: 0 2px 4px rgba(0,0,0,0.2)
}
.z_10 {
z-index: 10
}
.w_300px {
width: 300px
}
@@ -291,6 +59,12 @@
.transition_all_0\.3s_ease {
transition: all 0.3s ease
}
.\[\&_\[data-bead-type\]\]\:cursor_pointer [data-bead-type] {
cursor: pointer
}
.\[\&_\[data-bead-type\]\]\:transition_all_0\.2s_ease [data-bead-type] {
transition: all 0.2s ease
}
.fs_3xl {
font-size: var(--font-sizes-3xl)
@@ -452,34 +226,6 @@
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15)
}
.hover\:border_blue\.700:is(:hover, [data-hover]) {
border-color: var(--colors-blue-700)
}
.hover\:border_green\.700:is(:hover, [data-hover]) {
border-color: var(--colors-green-700)
}
.hover\:border_gray\.500:is(:hover, [data-hover]) {
border-color: var(--colors-gray-500)
}
.hover\:bg_blue\.200:is(:hover, [data-hover]) {
background: var(--colors-blue-200)
}
.hover\:transform_translateY\(-1px\):is(:hover, [data-hover]) {
transform: translateY(-1px)
}
.hover\:border_blue\.400:is(:hover, [data-hover]) {
border-color: var(--colors-blue-400)
}
.hover\:shadow_0_4px_12px_rgba\(0\,_0\,_0\,_0\.1\):is(:hover, [data-hover]) {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1)
}
.hover\:bg_gray\.200:is(:hover, [data-hover]) {
background: var(--colors-gray-200)
}
@@ -488,14 +234,24 @@
border-color: var(--colors-gray-400)
}
.\[\&\:hover\]\:border_amber\.700:hover {
border-color: var(--colors-amber-700)
.hover\:bg_blue\.200:is(:hover, [data-hover]) {
background: var(--colors-blue-200)
}
.\[\&\:hover\]\:transform_scale\(1\.01\):hover {
transform: scale(1.01)
.hover\:border_blue\.400:is(:hover, [data-hover]) {
border-color: var(--colors-blue-400)
}
.hover\:transform_translateY\(-1px\):is(:hover, [data-hover]) {
transform: translateY(-1px)
}
.\[\&_\[data-bead-type\]\]\:hover\:filter_brightness\(1\.2\) [data-bead-type]:is(:hover, [data-hover]) {
filter: brightness(1.2)
}
.\[\&_\[data-bead-type\]\]\:hover\:transform_scale\(1\.05\) [data-bead-type]:is(:hover, [data-hover]) {
transform: scale(1.05)
}
.active\:transform_scale\(0\.95\):is(:active, [data-active]) {
transform: scale(0.95)
}

View File

@@ -25,6 +25,10 @@
padding: 12px 16px
}
.gap_4px {
gap: 4px
}
.fs_11px {
font-size: 11px
}
@@ -37,6 +41,10 @@
padding: 10px 20px
}
.max-w_300px {
max-width: 300px
}
.min-w_50px {
min-width: 50px
}
@@ -77,6 +85,14 @@
transition: width 0.5s ease
}
.justify_flex-end {
justify-content: flex-end
}
.rounded_6px {
border-radius: 6px
}
.p_8px_16px {
padding: 8px 16px
}
@@ -318,6 +334,10 @@
box-shadow: 0 8px 25px rgba(40, 167, 69, 0.2)
}
.min-h_60px {
min-height: 60px
}
.p_16px_20px {
padding: 16px 20px
}
@@ -407,10 +427,26 @@
width: 100vw
}
.pointer-events_none {
pointer-events: none
}
.z_1000 {
z-index: 1000
}
.top_50\% {
top: 50%
}
.left_50\% {
left: 50%
}
.transform_translate\(-50\%\,_-50\%\) {
transform: translate(-50%, -50%)
}
.fs_72px {
font-size: 72px
}
@@ -447,6 +483,10 @@
margin: 0 auto
}
.justify_flex-start {
justify-content: flex-start
}
.text_gray\.800 {
color: var(--colors-gray-800)
}
@@ -511,6 +551,10 @@
padding: 12px 24px
}
.rounded_8px {
border-radius: 8px
}
.fs_16px {
font-size: 16px
}
@@ -619,6 +663,10 @@
--gradient-from: var(--colors-indigo-400)
}
.text_orange\.600 {
color: var(--colors-orange-600)
}
.ml_2 {
margin-left: var(--spacing-2)
}
@@ -895,6 +943,10 @@
width: var(--sizes-20)
}
.min-h_300px {
min-height: 300px
}
.max-w_sm {
max-width: var(--sizes-sm)
}
@@ -951,6 +1003,10 @@
padding: var(--spacing-6)
}
.max-w_400px {
max-width: 400px
}
.max-h_80vh {
max-height: 80vh
}
@@ -1306,146 +1362,6 @@
gap: var(--spacing-0)
}
.border_blue\.600 {
border-color: var(--colors-blue-600)
}
.min-h_300px {
min-height: 300px
}
.\[\&_svg\]\:transition_all_0\.2s_ease svg {
transition: all 0.2s ease
}
.mt_2 {
margin-top: var(--spacing-2)
}
.min-w_80px {
min-width: 80px
}
.max-w_300px {
max-width: 300px
}
.w_24px {
width: 24px
}
.h_8px {
height: 8px
}
.rounded_8px {
border-radius: 8px
}
.border_green\.600 {
border-color: var(--colors-green-600)
}
.border_gray\.400 {
border-color: var(--colors-gray-400)
}
.w_20px {
width: 20px
}
.h_6px {
height: 6px
}
.rounded_6px {
border-radius: 6px
}
.transition_border-color_0\.2s_ease {
transition: border-color 0.2s ease
}
.p_16px_8px {
padding: 16px 8px
}
.gap_8px {
gap: 8px
}
.min-h_60px {
min-height: 60px
}
.justify_flex-end {
justify-content: flex-end
}
.w_50px {
width: 50px
}
.h_4px {
height: 4px
}
.bg_gray\.800 {
background-color: var(--colors-gray-800)
}
.rounded_2px {
border-radius: 2px
}
.gap_4px {
gap: 4px
}
.min-h_80px {
min-height: 80px
}
.justify_flex-start {
justify-content: flex-start
}
.border_amber\.800 {
border-color: var(--colors-amber-800)
}
.max-w_400px {
max-width: 400px
}
.border_\#d97706 {
border-color: #d97706
}
.top_50\% {
top: 50%
}
.left_50\% {
left: 50%
}
.transform_translate\(-50\%\,_-50\%\) {
transform: translate(-50%, -50%)
}
.pointer-events_none {
pointer-events: none
}
.text_orange\.600 {
color: var(--colors-orange-600)
}
.text-shadow_0_2px_4px_rgba\(0\,0\,0\,0\.2\) {
text-shadow: 0 2px 4px rgba(0,0,0,0.2)
}
.w_300px {
width: 300px
}
@@ -1494,6 +1410,14 @@
transition: all 0.3s ease
}
.\[\&_\[data-bead-type\]\]\:cursor_pointer [data-bead-type] {
cursor: pointer
}
.\[\&_\[data-bead-type\]\]\:transition_all_0\.2s_ease [data-bead-type] {
transition: all 0.2s ease
}
.px_6 {
padding-inline: var(--spacing-6)
}
@@ -2196,34 +2120,6 @@
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15)
}
.hover\:border_blue\.700:is(:hover, [data-hover]) {
border-color: var(--colors-blue-700)
}
.hover\:border_green\.700:is(:hover, [data-hover]) {
border-color: var(--colors-green-700)
}
.hover\:border_gray\.500:is(:hover, [data-hover]) {
border-color: var(--colors-gray-500)
}
.hover\:bg_blue\.200:is(:hover, [data-hover]) {
background: var(--colors-blue-200)
}
.hover\:transform_translateY\(-1px\):is(:hover, [data-hover]) {
transform: translateY(-1px)
}
.hover\:border_blue\.400:is(:hover, [data-hover]) {
border-color: var(--colors-blue-400)
}
.hover\:shadow_0_4px_12px_rgba\(0\,_0\,_0\,_0\.1\):is(:hover, [data-hover]) {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1)
}
.hover\:bg_gray\.200:is(:hover, [data-hover]) {
background: var(--colors-gray-200)
}
@@ -2232,14 +2128,26 @@
border-color: var(--colors-gray-400)
}
.\[\&\:hover\]\:border_amber\.700:hover {
border-color: var(--colors-amber-700)
.hover\:bg_blue\.200:is(:hover, [data-hover]) {
background: var(--colors-blue-200)
}
.\[\&\:hover\]\:transform_scale\(1\.01\):hover {
transform: scale(1.01)
.hover\:border_blue\.400:is(:hover, [data-hover]) {
border-color: var(--colors-blue-400)
}
.hover\:transform_translateY\(-1px\):is(:hover, [data-hover]) {
transform: translateY(-1px)
}
.\[\&_\[data-bead-type\]\]\:hover\:filter_brightness\(1\.2\) [data-bead-type]:is(:hover, [data-hover]) {
filter: brightness(1.2)
}
.\[\&_\[data-bead-type\]\]\:hover\:transform_scale\(1\.05\) [data-bead-type]:is(:hover, [data-hover]) {
transform: scale(1.05)
}
.hover\:border_brand\.300:is(:hover, [data-hover]) {
border-color: var(--colors-brand-300)
}