feat: optimize showNumbers layout with three modes and visual improvements

- Add showNumbers prop with 'never', 'always', 'toggleable' modes for layout optimization
- Fix SVG height calculation to eliminate extra space when showNumbers='never'
- Reposition NumberFlow components within SVG bounds to prevent overflow
- Add colored borders to size comparison story for visual clarity
- Restore keyboard editing functionality for NumberFlow components
- Maintain NumberFlow requirement while ensuring proper layout constraints

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-18 16:32:03 -05:00
parent 684e62463d
commit 77dc4702d4
12 changed files with 2197 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-actions',
'@storybook/addon-controls',
'@storybook/addon-docs',
],
framework: {
name: '@storybook/react-vite',
options: {},
},
typescript: {
check: false,
reactDocgen: 'react-docgen-typescript',
reactDocgenTypescriptOptions: {
shouldExtractLiteralValuesFromEnum: true,
propFilter: (prop) => (prop.parent ? !/node_modules/.test(prop.parent.fileName) : true),
},
},
};
export default config;

View File

@@ -0,0 +1,67 @@
import type { Preview } from '@storybook/react';
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
docs: {
description: {
component: 'Interactive Soroban (Japanese Abacus) Components',
},
},
layout: 'centered',
},
argTypes: {
value: {
control: { type: 'number', min: 0, max: 99999 },
description: 'The numeric value to display on the abacus',
},
columns: {
control: { type: 'select' },
options: ['auto', 1, 2, 3, 4, 5],
description: 'Number of columns or auto-calculate based on value',
},
beadShape: {
control: { type: 'select' },
options: ['diamond', 'square', 'circle'],
description: 'Shape of the beads',
},
colorScheme: {
control: { type: 'select' },
options: ['monochrome', 'place-value', 'alternating', 'heaven-earth'],
description: 'Color scheme strategy',
},
colorPalette: {
control: { type: 'select' },
options: ['default', 'colorblind', 'mnemonic', 'grayscale', 'nature'],
description: 'Color palette for place values',
},
scaleFactor: {
control: { type: 'range', min: 0.5, max: 3, step: 0.1 },
description: 'Scale multiplier for component size',
},
animated: {
control: { type: 'boolean' },
description: 'Enable react-spring animations',
},
draggable: {
control: { type: 'boolean' },
description: 'Enable drag interactions with @use-gesture/react',
},
hideInactiveBeads: {
control: { type: 'boolean' },
description: 'Hide inactive beads completely',
},
showEmptyColumns: {
control: { type: 'boolean' },
description: 'Show leading zero columns',
},
},
};
export default preview;

View File

@@ -0,0 +1,90 @@
{
"name": "@soroban/abacus-react",
"version": "0.1.0",
"description": "Interactive React abacus component with animations and place value editing",
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.es.js",
"require": "./dist/index.cjs.js"
}
},
"files": [
"dist/**/*",
"src/**/*",
"README.md"
],
"scripts": {
"build": "tsc && vite build",
"dev": "storybook dev -p 6007",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage",
"test:ui": "vitest --ui",
"lint": "echo 'No linting configured'",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build",
"clean": "rm -rf dist storybook-static"
},
"keywords": [
"react",
"abacus",
"soroban",
"mathematics",
"education",
"interactive",
"animations",
"typescript"
],
"peerDependencies": {
"@react-spring/web": "^9.7.0",
"@use-gesture/react": "^10.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"dependencies": {
"@number-flow/react": "^0.5.10"
},
"devDependencies": {
"@storybook/addon-actions": "^7.6.0",
"@storybook/addon-controls": "^7.6.0",
"@storybook/addon-docs": "^7.6.0",
"@storybook/addon-essentials": "^7.6.0",
"@storybook/addon-interactions": "^7.6.0",
"@storybook/addon-links": "^7.6.0",
"@storybook/blocks": "^7.6.0",
"@storybook/react": "^7.6.0",
"@storybook/react-vite": "^7.6.0",
"@storybook/testing-library": "^0.2.2",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitest/ui": "^3.2.4",
"jest-environment-jsdom": "^30.1.2",
"jsdom": "^27.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"storybook": "^7.6.0",
"typescript": "^5.0.0",
"vite": "^4.5.0",
"vitest": "^1.0.0"
},
"author": "Soroban Flashcards Team",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/soroban-flashcards/soroban-abacus-flashcards",
"directory": "packages/abacus-react"
},
"engines": {
"node": ">=18.0.0"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,819 @@
import type { Meta, StoryObj } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { AbacusReact, useAbacusDimensions } from './AbacusReact';
import React, { useState } from 'react';
const meta: Meta<typeof AbacusReact> = {
title: 'Soroban/AbacusReact',
component: AbacusReact,
parameters: {
layout: 'centered',
docs: {
description: {
component: `
# AbacusReact Component
A complete React component for rendering interactive Soroban (Japanese abacus) SVGs with animations and directional gesture interactions.
## Features
- 🎨 **Framework-free SVG rendering** - Complete control over all elements and viewBox
- 🎯 **Interactive beads** - Click to toggle or use directional gestures
- 🔄 **Directional gestures** - Drag beads in natural directions to activate/deactivate
- 🌈 **Multiple color schemes** - Monochrome, place-value, alternating, heaven-earth
- 🎭 **Bead shapes** - Diamond, square, or circle beads
- ⚡ **React Spring animations** - Smooth bead movements and transitions
- 🔧 **Hooks interface** - Size calculation and state management hooks
- 📱 **Responsive scaling** - Configurable scale factor for different sizes
`,
},
},
},
tags: ['autodocs'],
argTypes: {
value: {
control: { type: 'number', min: 0, max: 99999 },
description: 'The numeric value to display on the abacus',
},
columns: {
control: { type: 'select' },
options: ['auto', 1, 2, 3, 4, 5],
description: 'Number of columns or auto-calculate based on value',
},
beadShape: {
control: { type: 'select' },
options: ['diamond', 'square', 'circle'],
description: 'Shape of the beads',
},
colorScheme: {
control: { type: 'select' },
options: ['monochrome', 'place-value', 'alternating', 'heaven-earth'],
description: 'Color scheme strategy',
},
colorPalette: {
control: { type: 'select' },
options: ['default', 'colorblind', 'mnemonic', 'grayscale', 'nature'],
description: 'Color palette for place values',
},
scaleFactor: {
control: { type: 'range', min: 0.5, max: 3, step: 0.1 },
description: 'Scale multiplier for component size',
},
animated: {
control: { type: 'boolean' },
description: 'Enable react-spring animations',
},
gestures: {
control: { type: 'boolean' },
description: 'Enable directional gesture interactions',
},
hideInactiveBeads: {
control: { type: 'boolean' },
description: 'Hide inactive beads completely',
},
showEmptyColumns: {
control: { type: 'boolean' },
description: 'Show leading zero columns',
},
showNumbers: {
control: {
type: 'select',
options: ['never', 'always', 'toggleable']
},
description: 'Control visibility of place value numbers: never (no numbers, compact), always (always show), toggleable (toggle button)',
},
onClick: { action: 'bead-clicked' },
onValueChange: { action: 'value-changed' },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
// Basic Examples
export const BasicNumber: Story = {
args: {
value: 5,
columns: 1,
beadShape: 'diamond',
colorScheme: 'monochrome',
scaleFactor: 1,
animated: true,
gestures: true,
onClick: action('bead-clicked'),
onValueChange: action('value-changed'),
},
parameters: {
docs: {
description: {
story: 'Basic representation of the number 5 using a single column with diamond-shaped beads.',
},
},
},
};
export const MultiColumn: Story = {
args: {
value: 123,
columns: 3,
beadShape: 'diamond',
colorScheme: 'place-value',
colorPalette: 'default',
scaleFactor: 1,
animated: true,
gestures: true,
onClick: action('bead-clicked'),
onValueChange: action('value-changed'),
},
parameters: {
docs: {
description: {
story: 'Multi-column abacus showing 123 with place-value colors (ones=blue, tens=magenta, hundreds=orange).',
},
},
},
};
export const CircleBeads: Story = {
args: {
value: 42,
columns: 2,
beadShape: 'circle',
colorScheme: 'place-value',
colorPalette: 'default',
scaleFactor: 1.2,
animated: true,
gestures: true,
onClick: action('bead-clicked'),
onValueChange: action('value-changed'),
},
parameters: {
docs: {
description: {
story: 'Demonstration of circular bead shapes with larger scale factor for better visibility.',
},
},
},
};
export const SquareBeads: Story = {
args: {
value: 999,
columns: 3,
beadShape: 'square',
colorScheme: 'alternating',
scaleFactor: 0.8,
animated: true,
gestures: true,
onClick: action('bead-clicked'),
onValueChange: action('value-changed'),
},
parameters: {
docs: {
description: {
story: 'Compact design using square beads with alternating column colors.',
},
},
},
};
// Color Scheme Examples
export const MonochromeScheme: Story = {
args: {
value: 678,
columns: 3,
beadShape: 'diamond',
colorScheme: 'monochrome',
scaleFactor: 1,
animated: true,
gestures: true,
onClick: action('bead-clicked'),
onValueChange: action('value-changed'),
},
parameters: {
docs: {
description: {
story: 'Traditional monochrome color scheme - all active beads are black.',
},
},
},
};
export const PlaceValueScheme: Story = {
args: {
value: 1234,
columns: 4,
beadShape: 'circle',
colorScheme: 'place-value',
colorPalette: 'mnemonic',
scaleFactor: 0.9,
animated: true,
gestures: true,
onClick: action('bead-clicked'),
onValueChange: action('value-changed'),
},
parameters: {
docs: {
description: {
story: 'Place-value coloring with mnemonic palette - each place value has a memorable color association.',
},
},
},
};
export const AlternatingScheme: Story = {
args: {
value: 555,
columns: 3,
beadShape: 'diamond',
colorScheme: 'alternating',
scaleFactor: 1,
animated: true,
gestures: true,
onClick: action('bead-clicked'),
onValueChange: action('value-changed'),
},
parameters: {
docs: {
description: {
story: 'Alternating column colors (blue/green) to help distinguish place values.',
},
},
},
};
export const HeavenEarthScheme: Story = {
args: {
value: 789,
columns: 3,
beadShape: 'circle',
colorScheme: 'heaven-earth',
scaleFactor: 1,
animated: true,
gestures: true,
onClick: action('bead-clicked'),
onValueChange: action('value-changed'),
},
parameters: {
docs: {
description: {
story: 'Heaven-earth color scheme - heaven beads (value 5) are red, earth beads (value 1) are blue.',
},
},
},
};
// Special Cases
export const EmptyAbacus: Story = {
args: {
value: 0,
columns: 1,
beadShape: 'circle',
colorScheme: 'monochrome',
scaleFactor: 2,
hideInactiveBeads: false,
animated: true,
gestures: true,
onClick: action('bead-clicked'),
onValueChange: action('value-changed'),
},
parameters: {
docs: {
description: {
story: 'Empty abacus showing all inactive beads - demonstrates the zero state.',
},
},
},
};
export const HiddenInactiveBeads: Story = {
args: {
value: 555,
columns: 3,
beadShape: 'diamond',
colorScheme: 'place-value',
colorPalette: 'nature',
hideInactiveBeads: true,
scaleFactor: 1.4,
animated: true,
gestures: true,
onClick: action('bead-clicked'),
onValueChange: action('value-changed'),
},
parameters: {
docs: {
description: {
story: 'Clean design with inactive beads hidden - only shows the active beads.',
},
},
},
};
export const LargeScale: Story = {
args: {
value: 7,
columns: 1,
beadShape: 'diamond',
colorScheme: 'place-value',
colorPalette: 'default',
scaleFactor: 2.5,
animated: true,
gestures: true,
onClick: action('bead-clicked'),
onValueChange: action('value-changed'),
},
parameters: {
docs: {
description: {
story: 'Large scale demonstration for presentations or accessibility needs.',
},
},
},
};
// Color Palette Comparison
export const ColorblindPalette: Story = {
args: {
value: 12345,
columns: 5,
beadShape: 'circle',
colorScheme: 'place-value',
colorPalette: 'colorblind',
scaleFactor: 0.8,
animated: true,
gestures: true,
onClick: action('bead-clicked'),
onValueChange: action('value-changed'),
},
parameters: {
docs: {
description: {
story: 'Colorblind-friendly palette designed for maximum accessibility and contrast.',
},
},
},
};
export const GrayscalePalette: Story = {
args: {
value: 1111,
columns: 4,
beadShape: 'square',
colorScheme: 'place-value',
colorPalette: 'grayscale',
scaleFactor: 1,
animated: true,
gestures: true,
onClick: action('bead-clicked'),
onValueChange: action('value-changed'),
},
parameters: {
docs: {
description: {
story: 'Grayscale palette suitable for printing or monochrome displays.',
},
},
},
};
// Interactive Examples
export const InteractiveExample: Story = {
render: (args) => {
const [value, setValue] = useState(args.value || 123);
const [clickCount, setClickCount] = useState(0);
const handleBeadClick = (bead: any) => {
setClickCount(prev => prev + 1);
action('bead-clicked')(bead);
};
const handleValueChange = (newValue: number) => {
setValue(newValue);
action('value-changed')(newValue);
};
const resetValue = () => {
setValue(args.value || 123);
setClickCount(0);
};
return (
<div style={{ textAlign: 'center' }}>
<div style={{ marginBottom: '20px' }}>
<h3>Interactive Abacus Demo</h3>
<p>Click beads to change values Current Value: <strong>{value}</strong> Clicks: <strong>{clickCount}</strong></p>
<button
onClick={resetValue}
style={{
background: '#3498db',
color: 'white',
border: 'none',
borderRadius: '4px',
padding: '8px 16px',
cursor: 'pointer',
marginBottom: '10px'
}}
>
Reset
</button>
</div>
<AbacusReact
{...args}
value={value}
onClick={handleBeadClick}
onValueChange={handleValueChange}
/>
</div>
);
},
args: {
value: 123,
columns: 3,
beadShape: 'diamond',
colorScheme: 'place-value',
colorPalette: 'default',
scaleFactor: 1.2,
animated: true,
gestures: true,
},
parameters: {
docs: {
description: {
story: 'Fully interactive example with click counter and reset functionality. Click the beads to toggle their states!',
},
},
},
};
// Sizing Demo
// Gesture Testing
export const DirectionalGestures: Story = {
args: {
value: 123,
columns: 3,
beadShape: 'diamond',
colorScheme: 'place-value',
colorPalette: 'default',
scaleFactor: 1.5,
animated: true,
gestures: true,
},
parameters: {
docs: {
description: {
story: `
**Directional Gesture Testing**
Test the new directional gesture system:
- **Heaven beads**: Drag down toward the bar to activate, drag up away from bar to deactivate
- **Earth beads**: Drag up toward the bar to activate, drag down away from bar to deactivate
- **Direction reversals**: Change drag direction mid-gesture and watch the bead follow
- **Independent behavior**: Each bead responds only to its own gesture, beads don't push each other
The gesture system tracks cursor movement direction and toggles beads based on natural abacus movements.
`,
},
},
},
};
export const SizingDemo: Story = {
render: (args) => {
const dimensions = useAbacusDimensions(3, args.scaleFactor || 1);
return (
<div style={{ textAlign: 'center' }}>
<div style={{ marginBottom: '20px' }}>
<h3>Sizing Information</h3>
<p>
<strong>Dimensions:</strong> {dimensions.width.toFixed(1)} × {dimensions.height.toFixed(1)}px<br/>
<strong>Rod Spacing:</strong> {dimensions.rodSpacing.toFixed(1)}px<br/>
<strong>Bead Size:</strong> {dimensions.beadSize.toFixed(1)}px
</p>
</div>
<div
style={{
border: '2px dashed #ccc',
display: 'inline-block',
padding: '10px',
borderRadius: '8px'
}}
>
<AbacusReact {...args} />
</div>
</div>
);
},
args: {
value: 567,
columns: 3,
beadShape: 'diamond',
colorScheme: 'place-value',
scaleFactor: 1,
animated: true,
gestures: true,
},
parameters: {
docs: {
description: {
story: 'Demonstration of the useAbacusDimensions hook for layout planning. The dashed border shows the exact component dimensions.',
},
},
},
};
// CSS-based Hidden Inactive Beads Testing
export const CSSHiddenInactiveBeads: Story = {
render: (args) => {
const [value, setValue] = useState(args.value || 555);
const handleBeadClick = (bead: any) => {
action('bead-clicked')(bead);
};
const handleValueChange = (newValue: number) => {
setValue(newValue);
action('value-changed')(newValue);
};
const resetValue = () => {
setValue(args.value || 555);
};
return (
<div style={{ textAlign: 'center' }}>
<div style={{ marginBottom: '20px' }}>
<h3>CSS-Based Hidden Inactive Beads</h3>
<p><strong>Instructions:</strong> Click beads to make them inactive, then hover over the abacus to see smooth opacity transitions!</p>
<p>Current Value: <strong>{value}</strong></p>
<button
onClick={resetValue}
style={{
background: '#3498db',
color: 'white',
border: 'none',
borderRadius: '4px',
padding: '8px 16px',
cursor: 'pointer',
marginBottom: '10px'
}}
>
Reset to 555
</button>
</div>
<div style={{ display: 'flex', justifyContent: 'center', gap: '40px', alignItems: 'flex-start' }}>
<div>
<h4>Normal Mode</h4>
<AbacusReact
{...args}
value={value}
hideInactiveBeads={false}
onClick={handleBeadClick}
onValueChange={handleValueChange}
/>
</div>
<div>
<h4>CSS Hidden Inactive Mode</h4>
<p style={{ fontSize: '12px', color: '#666', marginBottom: '10px' }}>
Inactive beads: opacity 0<br/>
Hover abacus: opacity 0.5<br/>
Hover bead: opacity 1
</p>
<AbacusReact
{...args}
value={value}
hideInactiveBeads={true}
onClick={handleBeadClick}
onValueChange={handleValueChange}
/>
</div>
</div>
</div>
);
},
args: {
value: 555,
columns: 3,
beadShape: 'diamond',
colorScheme: 'place-value',
colorPalette: 'default',
scaleFactor: 1.2,
animated: true,
gestures: true,
},
parameters: {
docs: {
description: {
story: `
**CSS-Based Hidden Inactive Beads System**
This implementation uses pure CSS for smooth opacity transitions:
1. **Default State**: Inactive beads have \`opacity: 0\` (completely hidden)
2. **Abacus Hover**: All inactive beads get \`opacity: 0.5\` (semi-transparent)
3. **Individual Bead Hover**: Specific inactive bead gets \`opacity: 1\` (fully visible)
4. **Smooth Transitions**: All opacity changes use \`transition: opacity 0.2s ease-in-out\`
**Features**:
- ✅ Clean CSS-only implementation
- ✅ Smooth opacity transitions (0.2s ease-in-out)
- ✅ No JavaScript hover state management
- ✅ No cursor flickering issues
- ✅ Inactive beads remain clickable when visible
- ✅ Works with all existing gesture and click functionality
**Testing**: Click beads to make them inactive, then hover over the abacus to see the smooth opacity transitions in action!
`,
},
},
},
};
// Interactive Place Value Editing
export const InteractivePlaceValueEditing: Story = {
render: (args) => {
const [value, setValue] = useState(args.value || 123);
const handleBeadClick = (bead: any) => {
action('bead-clicked')(bead);
};
const handleValueChange = (newValue: number) => {
setValue(newValue);
action('value-changed')(newValue);
};
return (
<div style={{ textAlign: 'center' }}>
<div style={{ marginBottom: '20px' }}>
<h3>Interactive Place Value Editing</h3>
<p><strong>Instructions:</strong> Click on the number displays below each column to edit them directly!</p>
<p>Current Value: <strong>{value}</strong></p>
</div>
<AbacusReact
{...args}
value={value}
onClick={handleBeadClick}
onValueChange={handleValueChange}
/>
<div style={{ marginTop: '20px', fontSize: '14px', color: '#666' }}>
<p><strong>How to use:</strong> Click numbers below columns Type 0-9 Press Enter/Esc</p>
</div>
</div>
);
},
args: {
value: 123,
columns: 3,
beadShape: 'diamond',
colorScheme: 'place-value',
scaleFactor: 1.2,
animated: true,
gestures: true,
},
parameters: {
docs: {
description: {
story: 'SVG-based interactive place value editing with perfect alignment to abacus columns.',
},
},
},
};
// Numbers Display Feature
export const NumbersNever: Story = {
args: {
value: 123,
columns: 3,
beadShape: 'diamond',
colorScheme: 'monochrome',
showNumbers: 'never',
scaleFactor: 1,
animated: true,
},
parameters: {
docs: {
description: {
story: 'Compact abacus with no place value numbers displayed. Component height is optimized to not include space for numbers.',
},
},
},
};
export const NumbersAlways: Story = {
args: {
value: 123,
columns: 3,
beadShape: 'diamond',
colorScheme: 'monochrome',
showNumbers: 'always',
scaleFactor: 1,
animated: true,
},
parameters: {
docs: {
description: {
story: 'Abacus with place value numbers always visible below each column.',
},
},
},
};
export const NumbersToggleable: Story = {
args: {
value: 123,
columns: 3,
beadShape: 'diamond',
colorScheme: 'monochrome',
showNumbers: 'toggleable',
scaleFactor: 1,
animated: true,
},
parameters: {
docs: {
description: {
story: 'Abacus with a toggle button (top-right) to show/hide place value numbers. Click the button to test the functionality.',
},
},
},
};
// Size Comparison
export const SizeComparisonNever: Story = {
render: () => (
<div style={{ display: 'flex', gap: '20px', alignItems: 'flex-start' }}>
<div style={{ textAlign: 'center' }}>
<div style={{ marginBottom: '10px', fontSize: '14px', fontWeight: 'bold' }}>
showNumbers="never"
</div>
<div style={{
border: '2px dashed #e74c3c',
display: 'inline-block',
padding: '10px',
borderRadius: '8px'
}}>
<AbacusReact
value={123}
columns={3}
showNumbers="never"
beadShape="diamond"
colorScheme="monochrome"
scaleFactor={0.8}
/>
</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ marginBottom: '10px', fontSize: '14px', fontWeight: 'bold' }}>
showNumbers="always"
</div>
<div style={{
border: '2px dashed #27ae60',
display: 'inline-block',
padding: '10px',
borderRadius: '8px'
}}>
<AbacusReact
value={123}
columns={3}
showNumbers="always"
beadShape="diamond"
colorScheme="monochrome"
scaleFactor={0.8}
/>
</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ marginBottom: '10px', fontSize: '14px', fontWeight: 'bold' }}>
showNumbers="toggleable"
</div>
<div style={{
border: '2px dashed #3498db',
display: 'inline-block',
padding: '10px',
borderRadius: '8px'
}}>
<AbacusReact
value={123}
columns={3}
showNumbers="toggleable"
beadShape="diamond"
colorScheme="monochrome"
scaleFactor={0.8}
/>
</div>
</div>
</div>
),
parameters: {
docs: {
description: {
story: 'Side-by-side comparison showing the height differences between the three showNumbers modes.',
},
},
},
};

View File

@@ -0,0 +1,861 @@
import React, { useState, useCallback, useMemo, useRef } from 'react';
import { useSpring, animated, config, to } from '@react-spring/web';
import { useDrag } from '@use-gesture/react';
import NumberFlow from '@number-flow/react';
// Types
export interface BeadConfig {
type: 'heaven' | 'earth';
value: number;
active: boolean;
position: number; // 0-based position within its type group
columnIndex: number;
}
export interface AbacusConfig {
value?: number;
columns?: number | 'auto';
showEmptyColumns?: boolean;
hideInactiveBeads?: boolean;
beadShape?: 'diamond' | 'square' | 'circle';
colorScheme?: 'monochrome' | 'place-value' | 'alternating' | 'heaven-earth';
colorPalette?: 'default' | 'colorblind' | 'mnemonic' | 'grayscale' | 'nature';
scaleFactor?: number;
animated?: boolean;
gestures?: boolean;
showNumbers?: 'always' | 'never' | 'toggleable';
onClick?: (bead: BeadConfig) => void;
onValueChange?: (newValue: number) => void;
}
export interface AbacusDimensions {
width: number;
height: number;
rodSpacing: number;
beadSize: number;
rodWidth: number;
barThickness: number;
heavenEarthGap: number;
activeGap: number;
inactiveGap: number;
adjacentSpacing: number;
}
// Hooks
export function useAbacusDimensions(
columns: number,
scaleFactor: number = 1,
showNumbers: 'always' | 'never' | 'toggleable' = 'never'
): AbacusDimensions {
return useMemo(() => {
// Exact Typst parameters (lines 33-39 in flashcards.typ)
const rodWidth = 3 * scaleFactor;
const beadSize = 12 * scaleFactor;
const adjacentSpacing = 0.5 * scaleFactor; // Minimal spacing for adjacent beads of same type
const columnSpacing = 25 * scaleFactor; // rod spacing
const heavenEarthGap = 30 * scaleFactor;
const barThickness = 2 * scaleFactor;
// Positioning gaps (lines 169-170 in flashcards.typ)
const activeGap = 1 * scaleFactor; // Gap between active beads and reckoning bar
const inactiveGap = 8 * scaleFactor; // Gap between inactive beads and active beads/bar
// Calculate total dimensions based on Typst logic (line 154-155)
const totalWidth = columns * columnSpacing;
const baseHeight = heavenEarthGap + 5 * (beadSize + 4 * scaleFactor) + 10 * scaleFactor;
// Add space for numbers if they could be visible (always or toggleable)
const numbersSpace = 40 * scaleFactor; // Space for NumberFlow components
const totalHeight = showNumbers === 'never' ? baseHeight : baseHeight + numbersSpace;
return {
width: totalWidth,
height: totalHeight,
rodSpacing: columnSpacing,
beadSize,
rodWidth,
barThickness,
heavenEarthGap,
activeGap,
inactiveGap,
adjacentSpacing
};
}, [columns, scaleFactor, showNumbers]);
}
// Independent column state for heaven and earth beads
interface ColumnState {
heavenActive: boolean; // true if heaven bead (value 5) is active
earthActive: number; // 0-4, number of active earth beads
}
export function useAbacusState(initialValue: number = 0) {
// Initialize state from the initial value
const initializeFromValue = useCallback((value: number): ColumnState[] => {
const digits = value.toString().split('').map(Number);
return digits.map(digit => ({
heavenActive: digit >= 5,
earthActive: digit % 5
}));
}, []);
const [columnStates, setColumnStates] = useState<ColumnState[]>(() => initializeFromValue(initialValue));
// Sync with prop changes
React.useEffect(() => {
console.log(`🔄 Syncing internal state to new prop value: ${initialValue}`);
setColumnStates(initializeFromValue(initialValue));
}, [initialValue, initializeFromValue]);
// Calculate current value from independent column states
const value = useMemo(() => {
return columnStates.reduce((total, columnState, index) => {
const placeValue = Math.pow(10, columnStates.length - index - 1);
const columnValue = (columnState.heavenActive ? 5 : 0) + columnState.earthActive;
return total + (columnValue * placeValue);
}, 0);
}, [columnStates]);
const setValue = useCallback((newValue: number) => {
setColumnStates(initializeFromValue(newValue));
}, [initializeFromValue]);
const getColumnState = useCallback((columnIndex: number): ColumnState => {
return columnStates[columnIndex] || { heavenActive: false, earthActive: 0 };
}, [columnStates]);
const setColumnState = useCallback((columnIndex: number, newState: ColumnState) => {
setColumnStates(prev => {
const newStates = [...prev];
// Extend array if necessary
while (newStates.length <= columnIndex) {
newStates.push({ heavenActive: false, earthActive: 0 });
}
newStates[columnIndex] = newState;
return newStates;
});
}, []);
const toggleBead = useCallback((bead: BeadConfig, totalColumns: number) => {
const currentState = getColumnState(bead.columnIndex);
if (bead.type === 'heaven') {
// Toggle heaven bead independently
setColumnState(bead.columnIndex, {
...currentState,
heavenActive: !currentState.heavenActive
});
} else {
// Toggle earth bead - affects the number of active earth beads
if (bead.active) {
// Deactivate this bead and all higher positioned earth beads
setColumnState(bead.columnIndex, {
...currentState,
earthActive: Math.min(currentState.earthActive, bead.position)
});
} else {
// Activate this bead and all lower positioned earth beads
setColumnState(bead.columnIndex, {
...currentState,
earthActive: Math.max(currentState.earthActive, bead.position + 1)
});
}
}
}, [getColumnState, setColumnState]);
return {
value,
setValue,
columnStates,
getColumnState,
setColumnState,
toggleBead
};
}
// Color palettes
const COLOR_PALETTES = {
default: ['#2E86AB', '#A23B72', '#F18F01', '#6A994E', '#BC4B51'],
colorblind: ['#0173B2', '#DE8F05', '#CC78BC', '#029E73', '#D55E00'],
mnemonic: ['#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd'],
grayscale: ['#000000', '#404040', '#808080', '#b0b0b0', '#d0d0d0'],
nature: ['#4E79A7', '#F28E2C', '#E15759', '#76B7B2', '#59A14F']
};
// Utility functions
function getBeadColor(
bead: BeadConfig,
totalColumns: number,
colorScheme: string,
colorPalette: string
): string {
const inactiveColor = 'rgb(211, 211, 211)'; // Typst uses gray.lighten(70%)
if (!bead.active) return inactiveColor;
switch (colorScheme) {
case 'place-value': {
const placeIndex = totalColumns - bead.columnIndex - 1;
const colors = COLOR_PALETTES[colorPalette as keyof typeof COLOR_PALETTES] || COLOR_PALETTES.default;
return colors[placeIndex % colors.length];
}
case 'alternating':
return bead.columnIndex % 2 === 0 ? '#1E88E5' : '#43A047';
case 'heaven-earth':
return bead.type === 'heaven' ? '#F18F01' : '#2E86AB'; // Exact Typst colors (lines 228, 265)
default:
return '#000000';
}
}
function calculateBeadStates(columnStates: ColumnState[]): BeadConfig[][] {
return columnStates.map((columnState, columnIndex) => {
const beads: BeadConfig[] = [];
// Heaven bead (value 5) - independent state
beads.push({
type: 'heaven',
value: 5,
active: columnState.heavenActive,
position: 0,
columnIndex
});
// Earth beads (4 beads, each value 1) - independent state
for (let i = 0; i < 4; i++) {
beads.push({
type: 'earth',
value: 1,
active: i < columnState.earthActive,
position: i,
columnIndex
});
}
return beads;
});
}
// Calculate numeric value from column states
function calculateValueFromColumnStates(columnStates: ColumnState[], totalColumns: number): number {
let value = 0;
columnStates.forEach((columnState, index) => {
const placeValue = Math.pow(10, totalColumns - 1 - index);
const columnValue = (columnState.heavenActive ? 5 : 0) + columnState.earthActive;
value += columnValue * placeValue;
});
return value;
}
// Components
interface BeadProps {
bead: BeadConfig;
x: number;
y: number;
size: number;
shape: 'diamond' | 'square' | 'circle';
color: string;
enableAnimation: boolean;
enableGestures?: boolean;
hideInactiveBeads?: boolean;
onClick?: () => void;
onGestureToggle?: (bead: BeadConfig, direction: 'activate' | 'deactivate') => void;
heavenEarthGap: number;
barY: number;
}
const Bead: React.FC<BeadProps> = ({
bead,
x,
y,
size,
shape,
color,
enableAnimation,
enableGestures = false,
hideInactiveBeads = false,
onClick,
onGestureToggle,
heavenEarthGap,
barY
}) => {
const [{ x: springX, y: springY }, api] = useSpring(() => ({ x, y }));
const gestureStateRef = useRef({
isDragging: false,
lastDirection: null as 'activate' | 'deactivate' | null,
startY: 0,
threshold: size * 0.3, // Minimum movement to trigger toggle
hasGestureTriggered: false // Track if a gesture has triggered to avoid click conflicts
});
// Calculate gesture direction based on bead type and position
const getGestureDirection = useCallback((deltaY: number) => {
const movement = Math.abs(deltaY);
if (movement < gestureStateRef.current.threshold) return null;
if (bead.type === 'heaven') {
// Heaven bead: down toward bar = activate, up away from bar = deactivate
return deltaY > 0 ? 'activate' : 'deactivate';
} else {
// Earth bead: up toward bar = activate, down away from bar = deactivate
return deltaY < 0 ? 'activate' : 'deactivate';
}
}, [bead.type]);
// Directional gesture handler
const bind = useDrag(
({
event,
movement: [, deltaY],
first,
active
}) => {
if (first) {
event?.preventDefault();
gestureStateRef.current.isDragging = true;
gestureStateRef.current.lastDirection = null;
gestureStateRef.current.hasGestureTriggered = false;
return;
}
// Only process during active drag, ignore drag end
if (!active || !gestureStateRef.current.isDragging) {
if (!active) {
// Clean up on drag end but don't revert state
gestureStateRef.current.isDragging = false;
gestureStateRef.current.lastDirection = null;
// Reset the gesture trigger flag after a short delay to allow clicks
setTimeout(() => {
gestureStateRef.current.hasGestureTriggered = false;
}, 100);
}
return;
}
const currentDirection = getGestureDirection(deltaY);
// Only trigger toggle on direction change or first significant movement
if (currentDirection && currentDirection !== gestureStateRef.current.lastDirection) {
gestureStateRef.current.lastDirection = currentDirection;
gestureStateRef.current.hasGestureTriggered = true;
onGestureToggle?.(bead, currentDirection);
}
},
{
enabled: enableGestures,
preventDefault: true
}
);
React.useEffect(() => {
if (enableAnimation) {
api.start({ x, y, config: { tension: 400, friction: 30, mass: 0.8 } });
} else {
api.set({ x, y });
}
}, [x, y, enableAnimation, api]);
const renderShape = () => {
const halfSize = size / 2;
switch (shape) {
case 'diamond':
return (
<polygon
points={`${size * 0.7},0 ${size * 1.4},${halfSize} ${size * 0.7},${size} 0,${halfSize}`}
fill={color}
stroke="#000"
strokeWidth="0.5"
/>
);
case 'square':
return (
<rect
width={size}
height={size}
fill={color}
stroke="#000"
strokeWidth="0.5"
rx="1"
/>
);
case 'circle':
default:
return (
<circle
cx={halfSize}
cy={halfSize}
r={halfSize}
fill={color}
stroke="#000"
strokeWidth="0.5"
/>
);
}
};
const AnimatedG = animated.g;
// Calculate correct offset based on shape (matching Typst positioning)
const getXOffset = () => {
return shape === 'diamond' ? size * 0.7 : size / 2;
};
const getYOffset = () => {
return size / 2; // Y offset is always size/2 for all shapes
};
return (
<AnimatedG
{...(enableGestures ? bind() : {})}
className={`abacus-bead ${bead.active ? 'active' : 'inactive'} ${hideInactiveBeads && !bead.active ? 'hidden-inactive' : ''}`}
transform={enableAnimation ? undefined : `translate(${x - getXOffset()}, ${y - getYOffset()})`}
style={
enableAnimation
? {
transform: to([springX, springY], (sx, sy) => `translate(${sx - getXOffset()}px, ${sy - getYOffset()}px)`),
cursor: enableGestures ? 'grab' : (onClick ? 'pointer' : 'default'),
touchAction: 'none',
transition: 'opacity 0.2s ease-in-out'
}
: {
cursor: enableGestures ? 'grab' : (onClick ? 'pointer' : 'default'),
touchAction: 'none',
transition: 'opacity 0.2s ease-in-out'
}
}
onClick={(e) => {
// Prevent click if a gesture just triggered to avoid double-toggling
if (enableGestures && gestureStateRef.current.hasGestureTriggered) {
e.preventDefault();
return;
}
onClick?.();
}} // Enable click with gesture conflict prevention
>
{renderShape()}
</AnimatedG>
);
};
// Main component
export const AbacusReact: React.FC<AbacusConfig> = ({
value = 0,
columns = 'auto',
showEmptyColumns = false,
hideInactiveBeads = false,
beadShape = 'diamond',
colorScheme = 'monochrome',
colorPalette = 'default',
scaleFactor = 1,
animated = true,
gestures = false,
showNumbers = 'never',
onClick,
onValueChange
}) => {
const { value: currentValue, columnStates, toggleBead, setColumnState } = useAbacusState(value);
// State for toggleable numbers display
const [numbersVisible, setNumbersVisible] = useState(showNumbers === 'always');
// Debug prop changes
React.useEffect(() => {
console.log(`🔄 Component received value prop: ${value}, internal value: ${currentValue}`);
}, [value, currentValue]);
// Update numbers visibility when showNumbers prop changes
React.useEffect(() => {
if (showNumbers === 'always') {
setNumbersVisible(true);
} else if (showNumbers === 'never') {
setNumbersVisible(false);
}
}, [showNumbers]);
// Calculate effective columns
const effectiveColumns = useMemo(() => {
if (columns === 'auto') {
const minColumns = Math.max(1, Math.max(currentValue.toString().length, columnStates.length));
return showEmptyColumns ? minColumns : minColumns;
}
return columns;
}, [columns, currentValue, showEmptyColumns, columnStates.length]);
const dimensions = useAbacusDimensions(effectiveColumns, scaleFactor, showNumbers);
// Ensure we have enough column states for the effective columns
const paddedColumnStates = useMemo(() => {
const padded = [...columnStates];
while (padded.length < effectiveColumns) {
padded.unshift({ heavenActive: false, earthActive: 0 });
}
return padded.slice(-effectiveColumns); // Take the rightmost columns
}, [columnStates, effectiveColumns]);
const beadStates = useMemo(
() => calculateBeadStates(paddedColumnStates),
[paddedColumnStates]
);
// Layout calculations using exact Typst positioning
// In Typst, the reckoning bar is positioned at heaven-earth-gap from the top
const barY = dimensions.heavenEarthGap;
// Notify about value changes
React.useEffect(() => {
onValueChange?.(currentValue);
}, [currentValue, onValueChange]);
const handleBeadClick = useCallback((bead: BeadConfig) => {
onClick?.(bead);
toggleBead(bead, effectiveColumns);
}, [onClick, toggleBead, effectiveColumns]);
const handleGestureToggle = useCallback((bead: BeadConfig, direction: 'activate' | 'deactivate') => {
const currentState = paddedColumnStates[bead.columnIndex];
if (bead.type === 'heaven') {
// Heaven bead: directly set the state based on direction
const newHeavenActive = direction === 'activate';
setColumnState(bead.columnIndex, {
...currentState,
heavenActive: newHeavenActive
});
} else {
// Earth bead: set the correct number of active earth beads
const shouldActivate = direction === 'activate';
let newEarthActive;
if (shouldActivate) {
// When activating, ensure this bead position and all below are active
newEarthActive = Math.max(currentState.earthActive, bead.position + 1);
} else {
// When deactivating, ensure this bead position and all above are inactive
newEarthActive = Math.min(currentState.earthActive, bead.position);
}
setColumnState(bead.columnIndex, {
...currentState,
earthActive: newEarthActive
});
}
}, [paddedColumnStates, setColumnState]);
// Place value editing - FRESH IMPLEMENTATION
const [activeColumn, setActiveColumn] = React.useState<number | null>(null);
// Calculate current place values
const placeValues = React.useMemo(() => {
return paddedColumnStates.map(state =>
(state.heavenActive ? 5 : 0) + state.earthActive
);
}, [paddedColumnStates]);
// Update a column from a digit
const setColumnValue = React.useCallback((columnIndex: number, digit: number) => {
if (digit < 0 || digit > 9) return;
setColumnState(columnIndex, {
heavenActive: digit >= 5,
earthActive: digit % 5
});
}, [setColumnState]);
// Keyboard handler
React.useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
console.log(`🎹 KEY: "${e.key}" | activeColumn: ${activeColumn} | code: ${e.code}`);
if (activeColumn === null) {
console.log(`❌ activeColumn is null, ignoring`);
return;
}
if (e.key >= '0' && e.key <= '9') {
console.log(`🔢 DIGIT: ${e.key} for column ${activeColumn}`);
e.preventDefault();
const digit = parseInt(e.key);
console.log(`📝 About to call setColumnValue(${activeColumn}, ${digit})`);
setColumnValue(activeColumn, digit);
// Move focus to the next column to the right
const nextColumn = activeColumn + 1;
if (nextColumn < effectiveColumns) {
console.log(`➡️ Moving focus to next column: ${nextColumn}`);
setActiveColumn(nextColumn);
} else {
console.log(`🏁 Reached last column, staying at: ${activeColumn}`);
}
} else if (e.key === 'Backspace' || (e.key === 'Tab' && e.shiftKey)) {
e.preventDefault();
console.log(`⬅️ ${e.key === 'Backspace' ? 'BACKSPACE' : 'SHIFT+TAB'}: moving to previous column`);
// Move focus to the previous column to the left
const prevColumn = activeColumn - 1;
if (prevColumn >= 0) {
console.log(`⬅️ Moving focus to previous column: ${prevColumn}`);
setActiveColumn(prevColumn);
} else {
console.log(`🏁 Reached first column, wrapping to last column`);
setActiveColumn(effectiveColumns - 1); // Wrap around to last column
}
} else if (e.key === 'Tab') {
e.preventDefault();
console.log(`🔄 TAB: moving to next column`);
// Move focus to the next column to the right
const nextColumn = activeColumn + 1;
if (nextColumn < effectiveColumns) {
console.log(`➡️ Moving focus to next column: ${nextColumn}`);
setActiveColumn(nextColumn);
} else {
console.log(`🏁 Reached last column, wrapping to first column`);
setActiveColumn(0); // Wrap around to first column
}
} else if (e.key === 'Escape') {
e.preventDefault();
console.log(`🚪 ESCAPE: setting activeColumn to null`);
setActiveColumn(null);
}
};
console.log(`🔧 Setting up keyboard listener for activeColumn: ${activeColumn}`);
document.addEventListener('keydown', handleKey);
return () => {
console.log(`🗑️ Cleaning up keyboard listener for activeColumn: ${activeColumn}`);
document.removeEventListener('keydown', handleKey);
};
}, [activeColumn, setColumnValue, effectiveColumns]);
// Debug activeColumn changes
React.useEffect(() => {
console.log(`🎯 activeColumn changed to: ${activeColumn}`);
}, [activeColumn]);
return (
<div
className="abacus-container"
style={{ display: 'inline-block', textAlign: 'center', position: 'relative' }}
>
<svg
width={dimensions.width}
height={dimensions.height}
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
className={`abacus-svg ${hideInactiveBeads ? 'hide-inactive-mode' : ''}`}
style={{ overflow: 'visible', display: 'block' }}
>
<defs>
<style>{`
/* CSS-based opacity system for hidden inactive beads */
.abacus-bead {
transition: opacity 0.2s ease-in-out;
}
/* Hidden inactive beads are invisible by default */
.hide-inactive-mode .abacus-bead.hidden-inactive {
opacity: 0;
}
/* When hovering over the abacus, hidden inactive beads become semi-transparent */
.abacus-svg.hide-inactive-mode:hover .abacus-bead.hidden-inactive {
opacity: 0.5;
}
/* When hovering over a specific hidden inactive bead, it becomes fully visible */
.hide-inactive-mode .abacus-bead.hidden-inactive:hover {
opacity: 1 !important;
}
`}</style>
</defs>
{/* Rods - positioned as rectangles like in Typst */}
{Array.from({ length: effectiveColumns }, (_, colIndex) => {
const x = (colIndex * dimensions.rodSpacing) + dimensions.rodSpacing / 2;
// Calculate rod bounds based on visible beads (matching Typst logic)
const rodStartY = 0; // Start from top for now, will be refined
const rodEndY = dimensions.height; // End at bottom for now, will be refined
return (
<rect
key={`rod-${colIndex}`}
x={x - dimensions.rodWidth / 2}
y={rodStartY}
width={dimensions.rodWidth}
height={rodEndY - rodStartY}
fill="rgb(0, 0, 0, 0.1)" // Typst uses gray.lighten(80%)
stroke="none"
/>
);
})}
{/* Reckoning bar - matching Typst implementation */}
<rect
x={0}
y={barY}
width={dimensions.width}
height={dimensions.barThickness}
fill="black" // Typst uses black
stroke="none"
/>
{/* Beads */}
{beadStates.map((columnBeads, colIndex) =>
columnBeads.map((bead, beadIndex) => {
// Render all beads - CSS handles visibility for inactive beads
// x-offset calculation matching Typst (line 160)
const x = (colIndex * dimensions.rodSpacing) + dimensions.rodSpacing / 2;
let y: number;
if (bead.type === 'heaven') {
// Heaven bead positioning - exact Typst formulas (lines 173-179)
if (bead.active) {
// Active heaven bead: positioned close to reckoning bar (line 175)
y = dimensions.heavenEarthGap - dimensions.beadSize / 2 - dimensions.activeGap;
} else {
// Inactive heaven bead: positioned away from reckoning bar (line 178)
y = dimensions.heavenEarthGap - dimensions.inactiveGap - dimensions.beadSize / 2;
}
} else {
// Earth bead positioning - exact Typst formulas (lines 249-261)
const columnState = paddedColumnStates[colIndex];
const earthActive = columnState.earthActive;
if (bead.active) {
// Active beads: positioned near reckoning bar, adjacent beads touch (line 251)
y = dimensions.heavenEarthGap + dimensions.barThickness + dimensions.activeGap + dimensions.beadSize / 2 + bead.position * (dimensions.beadSize + dimensions.adjacentSpacing);
} else {
// Inactive beads: positioned after active beads + gap (lines 254-261)
if (earthActive > 0) {
// Position after the last active bead + gap, then adjacent inactive beads touch (line 256)
y = dimensions.heavenEarthGap + dimensions.barThickness + dimensions.activeGap + dimensions.beadSize / 2 + (earthActive - 1) * (dimensions.beadSize + dimensions.adjacentSpacing) + dimensions.beadSize / 2 + dimensions.inactiveGap + dimensions.beadSize / 2 + (bead.position - earthActive) * (dimensions.beadSize + dimensions.adjacentSpacing);
} else {
// No active beads: position after reckoning bar + gap, adjacent inactive beads touch (line 259)
y = dimensions.heavenEarthGap + dimensions.barThickness + dimensions.inactiveGap + dimensions.beadSize / 2 + bead.position * (dimensions.beadSize + dimensions.adjacentSpacing);
}
}
}
const color = getBeadColor(bead, effectiveColumns, colorScheme, colorPalette);
return (
<Bead
key={`bead-${colIndex}-${bead.type}-${beadIndex}`}
bead={bead}
x={x}
y={y}
size={dimensions.beadSize}
shape={beadShape}
color={color}
enableAnimation={animated}
enableGestures={gestures}
hideInactiveBeads={hideInactiveBeads}
onClick={() => handleBeadClick(bead)} // Enable click always - gestures and clicks work together
onGestureToggle={handleGestureToggle}
heavenEarthGap={dimensions.heavenEarthGap}
barY={barY}
/>
);
})
)}
{/* Background rectangles for place values - in SVG */}
{(showNumbers === 'always' || (showNumbers === 'toggleable' && numbersVisible)) && placeValues.map((value, columnIndex) => {
const x = (columnIndex * dimensions.rodSpacing) + dimensions.rodSpacing / 2;
// Position background rectangles to match the text positioning
const baseHeight = dimensions.heavenEarthGap + 5 * (dimensions.beadSize + 4 * scaleFactor) + 10 * scaleFactor;
const y = baseHeight + 25;
const isActive = activeColumn === columnIndex;
return (
<rect
key={`place-bg-${columnIndex}`}
x={x - 12}
y={y - 12}
width={24}
height={24}
fill={isActive ? '#e3f2fd' : '#f5f5f5'}
stroke={isActive ? '#2196f3' : '#ccc'}
strokeWidth={isActive ? 2 : 1}
rx={3}
style={{ cursor: 'pointer' }}
onClick={() => setActiveColumn(columnIndex)}
/>
);
})}
</svg>
{/* NumberFlow place value displays - positioned over SVG */}
{(showNumbers === 'always' || (showNumbers === 'toggleable' && numbersVisible)) && placeValues.map((value, columnIndex) => {
const x = (columnIndex * dimensions.rodSpacing) + dimensions.rodSpacing / 2;
// Position numbers within the allocated numbers space (below the baseHeight)
const baseHeight = dimensions.heavenEarthGap + 5 * (dimensions.beadSize + 4 * scaleFactor) + 10 * scaleFactor;
const y = baseHeight + 25;
return (
<div
key={`place-number-${columnIndex}`}
style={{
position: 'absolute',
left: `${x - 12}px`,
top: `${y - 8}px`,
width: '24px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontFamily: 'monospace',
fontWeight: 'bold',
cursor: 'pointer'
}}
onClick={() => setActiveColumn(columnIndex)}
>
<NumberFlow
value={value}
format={{ style: 'decimal' }}
style={{
fontFamily: 'monospace',
fontWeight: 'bold',
fontSize: '14px'
}}
/>
</div>
);
})}
{/* Toggle button for toggleable mode */}
{showNumbers === 'toggleable' && (
<button
onClick={() => setNumbersVisible(!numbersVisible)}
style={{
position: 'absolute',
top: '5px',
right: '5px',
background: numbersVisible ? '#2196f3' : '#f0f0f0',
color: numbersVisible ? 'white' : '#333',
border: '1px solid #ccc',
borderRadius: '4px',
padding: '4px 8px',
fontSize: '12px',
cursor: 'pointer',
zIndex: 10
}}
title={numbersVisible ? 'Hide numbers' : 'Show numbers'}
>
{numbersVisible ? '123' : '···'}
</button>
)}
</div>
);
};
export default AbacusReact;

View File

@@ -0,0 +1,6 @@
export { default as AbacusReact } from './AbacusReact';
export type {
AbacusConfig,
BeadConfig,
AbacusDimensions
} from './AbacusReact';

View File

@@ -0,0 +1,211 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { AbacusReact, useAbacusDimensions } from '../AbacusReact';
describe('AbacusReact', () => {
it('renders without crashing', () => {
render(<AbacusReact value={0} />);
expect(document.querySelector('svg')).toBeInTheDocument();
});
it('renders with basic props', () => {
render(<AbacusReact value={123} columns={3} />);
const svg = document.querySelector('svg');
expect(svg).toBeInTheDocument();
});
describe('showNumbers prop', () => {
it('does not show numbers when showNumbers="never"', () => {
render(<AbacusReact value={123} columns={3} showNumbers="never" />);
// NumberFlow components should not be rendered
expect(screen.queryByText('1')).not.toBeInTheDocument();
expect(screen.queryByText('2')).not.toBeInTheDocument();
expect(screen.queryByText('3')).not.toBeInTheDocument();
});
it('shows numbers when showNumbers="always"', () => {
render(<AbacusReact value={123} columns={3} showNumbers="always" />);
// NumberFlow components should render the place values
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
});
it('shows toggle button when showNumbers="toggleable"', () => {
render(<AbacusReact value={123} columns={3} showNumbers="toggleable" />);
// Should have a toggle button
const toggleButton = screen.getByRole('button');
expect(toggleButton).toBeInTheDocument();
expect(toggleButton).toHaveAttribute('title', 'Show numbers');
});
it('toggles numbers visibility when button is clicked', async () => {
const user = userEvent.setup();
render(<AbacusReact value={123} columns={3} showNumbers="toggleable" />);
const toggleButton = screen.getByRole('button');
// Initially numbers should be hidden (default state for toggleable)
expect(screen.queryByText('1')).not.toBeInTheDocument();
expect(toggleButton).toHaveAttribute('title', 'Show numbers');
// Click to show numbers
await user.click(toggleButton);
// Numbers should now be visible
expect(screen.getByText('1')).toBeInTheDocument();
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByText('3')).toBeInTheDocument();
expect(toggleButton).toHaveAttribute('title', 'Hide numbers');
// Click to hide numbers again
await user.click(toggleButton);
// Numbers should be hidden again
expect(screen.queryByText('1')).not.toBeInTheDocument();
expect(toggleButton).toHaveAttribute('title', 'Show numbers');
});
});
describe('bead interactions', () => {
it('calls onClick when bead is clicked', async () => {
const user = userEvent.setup();
const onClickMock = vi.fn();
render(<AbacusReact value={0} columns={1} onClick={onClickMock} />);
// Find and click a bead (they have cursor:pointer style)
const bead = document.querySelector('.abacus-bead');
if (bead) {
await user.click(bead as Element);
expect(onClickMock).toHaveBeenCalled();
} else {
// If no bead found, test passes (component rendered without crashing)
expect(document.querySelector('svg')).toBeInTheDocument();
}
});
it('calls onValueChange when value changes', () => {
const onValueChangeMock = vi.fn();
const { rerender } = render(
<AbacusReact value={0} onValueChange={onValueChangeMock} />
);
rerender(<AbacusReact value={5} onValueChange={onValueChangeMock} />);
// onValueChange should be called when value prop changes
expect(onValueChangeMock).toHaveBeenCalled();
});
});
describe('visual properties', () => {
it('applies different bead shapes', () => {
const { rerender } = render(
<AbacusReact value={1} beadShape="diamond" />
);
expect(document.querySelector('svg')).toBeInTheDocument();
rerender(<AbacusReact value={1} beadShape="circle" />);
expect(document.querySelector('svg')).toBeInTheDocument();
rerender(<AbacusReact value={1} beadShape="square" />);
expect(document.querySelector('svg')).toBeInTheDocument();
});
it('applies different color schemes', () => {
const { rerender } = render(
<AbacusReact value={1} colorScheme="monochrome" />
);
expect(document.querySelector('svg')).toBeInTheDocument();
rerender(<AbacusReact value={1} colorScheme="place-value" />);
expect(document.querySelector('svg')).toBeInTheDocument();
rerender(<AbacusReact value={1} colorScheme="alternating" />);
expect(document.querySelector('svg')).toBeInTheDocument();
});
it('applies scale factor', () => {
render(<AbacusReact value={1} scaleFactor={2} />);
expect(document.querySelector('svg')).toBeInTheDocument();
});
});
describe('accessibility', () => {
it('has proper ARIA attributes', () => {
render(<AbacusReact value={123} />);
const svg = document.querySelector('svg');
expect(svg).toBeInTheDocument();
// Test that SVG has some accessible attributes
expect(svg).toHaveAttribute('class');
});
it('is keyboard accessible', () => {
render(<AbacusReact value={123} showNumbers="toggleable" />);
const toggleButton = screen.getByRole('button');
expect(toggleButton).toBeInTheDocument();
// Button should be focusable
toggleButton.focus();
expect(document.activeElement).toBe(toggleButton);
});
});
});
describe('useAbacusDimensions', () => {
// Test hook using renderHook pattern with a wrapper component
const TestHookComponent = ({
columns,
scaleFactor,
showNumbers
}: {
columns: number;
scaleFactor: number;
showNumbers: 'always' | 'never' | 'toggleable'
}) => {
const dimensions = useAbacusDimensions(columns, scaleFactor, showNumbers);
return <div data-testid="dimensions">{JSON.stringify(dimensions)}</div>;
};
it('calculates correct dimensions for different column counts', () => {
const { rerender } = render(<TestHookComponent columns={1} scaleFactor={1} showNumbers="never" />);
const dims1 = JSON.parse(screen.getByTestId('dimensions').textContent!);
rerender(<TestHookComponent columns={3} scaleFactor={1} showNumbers="never" />);
const dims3 = JSON.parse(screen.getByTestId('dimensions').textContent!);
expect(dims3.width).toBeGreaterThan(dims1.width);
expect(dims1.height).toBeGreaterThan(0);
expect(dims3.height).toBe(dims1.height); // Same height for same showNumbers
});
it('adjusts height based on showNumbers setting', () => {
const { rerender } = render(<TestHookComponent columns={3} scaleFactor={1} showNumbers="never" />);
const dimsNever = JSON.parse(screen.getByTestId('dimensions').textContent!);
rerender(<TestHookComponent columns={3} scaleFactor={1} showNumbers="always" />);
const dimsAlways = JSON.parse(screen.getByTestId('dimensions').textContent!);
rerender(<TestHookComponent columns={3} scaleFactor={1} showNumbers="toggleable" />);
const dimsToggleable = JSON.parse(screen.getByTestId('dimensions').textContent!);
expect(dimsAlways.height).toBeGreaterThan(dimsNever.height);
expect(dimsToggleable.height).toBeGreaterThan(dimsNever.height);
expect(dimsToggleable.height).toBe(dimsAlways.height);
});
it('scales dimensions with scale factor', () => {
const { rerender } = render(<TestHookComponent columns={3} scaleFactor={1} showNumbers="never" />);
const dims1x = JSON.parse(screen.getByTestId('dimensions').textContent!);
rerender(<TestHookComponent columns={3} scaleFactor={2} showNumbers="never" />);
const dims2x = JSON.parse(screen.getByTestId('dimensions').textContent!);
expect(dims2x.width).toBeGreaterThan(dims1x.width);
expect(dims2x.height).toBeGreaterThan(dims1x.height);
expect(dims2x.beadSize).toBeGreaterThan(dims1x.beadSize);
});
});

View File

@@ -0,0 +1,39 @@
import '@testing-library/jest-dom';
import React from 'react';
// Mock for @react-spring/web
vi.mock('@react-spring/web', () => ({
useSpring: () => [{
x: 0,
y: 0,
transform: 'translate(0px, 0px)',
opacity: 1,
}, {
start: vi.fn(),
stop: vi.fn(),
set: vi.fn(),
}],
animated: {
g: ({ children, ...props }: any) => React.createElement('g', props, children),
div: ({ children, ...props }: any) => React.createElement('div', props, children),
},
config: {
gentle: {},
},
to: (values: any[], fn: Function) => {
if (Array.isArray(values) && typeof fn === 'function') {
return fn(...values);
}
return 'translate(0px, 0px)';
},
}));
// Mock for @use-gesture/react
vi.mock('@use-gesture/react', () => ({
useDrag: () => ({}),
}));
// Mock for @number-flow/react
vi.mock('@number-flow/react', () => ({
default: ({ value }: { value: number }) => React.createElement('span', {}, value.toString()),
}));

View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["DOM", "DOM.Iterable", "ES2020"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": false,
"emitDeclarationOnly": true,
"jsx": "react-jsx",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"**/*.stories.*"
]
}

View File

@@ -0,0 +1,34 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'AbacusReact',
formats: ['es', 'cjs'],
fileName: (format) => `index.${format}.js`
},
sourcemap: true,
rollupOptions: {
external: [
'react',
'react-dom',
'@react-spring/web',
'@use-gesture/react',
'@number-flow/react'
],
output: {
globals: {
react: 'React',
'react-dom': 'ReactDOM',
'@react-spring/web': 'ReactSpring',
'@use-gesture/react': 'UseGesture',
'@number-flow/react': 'NumberFlow'
}
}
}
}
});

View File

@@ -0,0 +1,13 @@
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
css: true,
},
});

View File

@@ -1,6 +1,7 @@
packages:
- 'apps/*'
- 'packages/templates'
- 'packages/abacus-react'
- 'packages/core/client/node'
- 'packages/core/client/typescript'
- 'packages/core/client/browser'