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:
27
packages/abacus-react/.storybook/main.ts
Normal file
27
packages/abacus-react/.storybook/main.ts
Normal 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;
|
||||
67
packages/abacus-react/.storybook/preview.ts
Normal file
67
packages/abacus-react/.storybook/preview.ts
Normal 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;
|
||||
90
packages/abacus-react/package.json
Normal file
90
packages/abacus-react/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
819
packages/abacus-react/src/AbacusReact.stories.tsx
Normal file
819
packages/abacus-react/src/AbacusReact.stories.tsx
Normal 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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
861
packages/abacus-react/src/AbacusReact.tsx
Normal file
861
packages/abacus-react/src/AbacusReact.tsx
Normal 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;
|
||||
6
packages/abacus-react/src/index.ts
Normal file
6
packages/abacus-react/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as AbacusReact } from './AbacusReact';
|
||||
export type {
|
||||
AbacusConfig,
|
||||
BeadConfig,
|
||||
AbacusDimensions
|
||||
} from './AbacusReact';
|
||||
211
packages/abacus-react/src/test/AbacusReact.test.tsx
Normal file
211
packages/abacus-react/src/test/AbacusReact.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
39
packages/abacus-react/src/test/setup.ts
Normal file
39
packages/abacus-react/src/test/setup.ts
Normal 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()),
|
||||
}));
|
||||
29
packages/abacus-react/tsconfig.json
Normal file
29
packages/abacus-react/tsconfig.json
Normal 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.*"
|
||||
]
|
||||
}
|
||||
34
packages/abacus-react/vite.config.ts
Normal file
34
packages/abacus-react/vite.config.ts
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
13
packages/abacus-react/vitest.config.ts
Normal file
13
packages/abacus-react/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
packages:
|
||||
- 'apps/*'
|
||||
- 'packages/templates'
|
||||
- 'packages/abacus-react'
|
||||
- 'packages/core/client/node'
|
||||
- 'packages/core/client/typescript'
|
||||
- 'packages/core/client/browser'
|
||||
Reference in New Issue
Block a user