feat(abacus-react): add core utility functions for state management
Add AbacusUtils.ts with comprehensive utility functions: - numberToAbacusState: Convert numbers to bead positions - abacusStateToNumber: Convert bead positions to numbers - calculateBeadChanges: Find bead differences between states - calculateBeadDiff: Full diff with order and directions - calculateBeadDiffFromValues: Convenience wrapper - validateAbacusValue: Validate number ranges - areStatesEqual: Compare abacus states These utilities eliminate ~200 lines of duplicate code in apps/web. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
358
packages/abacus-react/src/AbacusUtils.ts
Normal file
358
packages/abacus-react/src/AbacusUtils.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Utility functions for working with abacus states and calculations
|
||||
* These help convert between numbers and bead positions, calculate diffs, etc.
|
||||
*/
|
||||
|
||||
import type { ValidPlaceValues, BeadHighlight } from './AbacusReact'
|
||||
|
||||
/**
|
||||
* Represents the state of beads in a single column
|
||||
*/
|
||||
export interface BeadState {
|
||||
heavenActive: boolean
|
||||
earthActive: number // 0-4
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the complete state of an abacus
|
||||
* Key is the place value (0 = ones, 1 = tens, etc.)
|
||||
*/
|
||||
export interface AbacusState {
|
||||
[placeValue: number]: BeadState
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a number to abacus state representation
|
||||
* @param value - The number to convert
|
||||
* @param maxPlaces - Maximum number of place values to include
|
||||
* @returns AbacusState object representing the bead positions
|
||||
*/
|
||||
export function numberToAbacusState(value: number | bigint, maxPlaces: number = 5): AbacusState {
|
||||
const state: AbacusState = {}
|
||||
const valueNum = typeof value === 'bigint' ? Number(value) : value
|
||||
|
||||
for (let place = 0; place < maxPlaces; place++) {
|
||||
const placeValueNum = 10 ** place
|
||||
const digit = Math.floor(valueNum / placeValueNum) % 10
|
||||
|
||||
state[place] = {
|
||||
heavenActive: digit >= 5,
|
||||
earthActive: digit >= 5 ? digit - 5 : digit,
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert abacus state to a number
|
||||
* @param state - The abacus state to convert
|
||||
* @returns The numeric value represented by the abacus
|
||||
*/
|
||||
export function abacusStateToNumber(state: AbacusState): number {
|
||||
let total = 0
|
||||
|
||||
for (const placeStr in state) {
|
||||
const place = parseInt(placeStr, 10)
|
||||
const beadState = state[place]
|
||||
const digit = (beadState.heavenActive ? 5 : 0) + beadState.earthActive
|
||||
total += digit * (10 ** place)
|
||||
}
|
||||
|
||||
return total
|
||||
}
|
||||
|
||||
/**
|
||||
* Bead highlight with place value (internal type for calculations)
|
||||
*/
|
||||
export interface PlaceValueBasedBead {
|
||||
placeValue: ValidPlaceValues
|
||||
beadType: 'heaven' | 'earth'
|
||||
position?: 0 | 1 | 2 | 3
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate which beads need to change between two abacus states
|
||||
* @param startState - The starting abacus state
|
||||
* @param targetState - The target abacus state
|
||||
* @returns Object with arrays of bead additions and removals
|
||||
*/
|
||||
export function calculateBeadChanges(
|
||||
startState: AbacusState,
|
||||
targetState: AbacusState
|
||||
): {
|
||||
additions: PlaceValueBasedBead[]
|
||||
removals: PlaceValueBasedBead[]
|
||||
placeValue: number
|
||||
} {
|
||||
const additions: PlaceValueBasedBead[] = []
|
||||
const removals: PlaceValueBasedBead[] = []
|
||||
let mainPlaceValue = 0
|
||||
|
||||
for (const placeStr in targetState) {
|
||||
const place = parseInt(placeStr, 10) as ValidPlaceValues
|
||||
const start = startState[place] || { heavenActive: false, earthActive: 0 }
|
||||
const target = targetState[place]
|
||||
|
||||
// Check heaven bead changes
|
||||
if (!start.heavenActive && target.heavenActive) {
|
||||
additions.push({ placeValue: place, beadType: 'heaven' })
|
||||
mainPlaceValue = place
|
||||
} else if (start.heavenActive && !target.heavenActive) {
|
||||
removals.push({ placeValue: place, beadType: 'heaven' })
|
||||
mainPlaceValue = place
|
||||
}
|
||||
|
||||
// Check earth bead changes
|
||||
if (target.earthActive > start.earthActive) {
|
||||
// Adding earth beads
|
||||
for (let pos = start.earthActive; pos < target.earthActive; pos++) {
|
||||
additions.push({ placeValue: place, beadType: 'earth', position: pos as 0 | 1 | 2 | 3 })
|
||||
mainPlaceValue = place
|
||||
}
|
||||
} else if (target.earthActive < start.earthActive) {
|
||||
// Removing earth beads
|
||||
for (let pos = start.earthActive - 1; pos >= target.earthActive; pos--) {
|
||||
removals.push({ placeValue: place, beadType: 'earth', position: pos as 0 | 1 | 2 | 3 })
|
||||
mainPlaceValue = place
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { additions, removals, placeValue: mainPlaceValue }
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a bead diff calculation
|
||||
*/
|
||||
export interface BeadDiffResult {
|
||||
placeValue: ValidPlaceValues
|
||||
beadType: 'heaven' | 'earth'
|
||||
position?: number
|
||||
direction: 'activate' | 'deactivate'
|
||||
order: number // Order of operations for animations
|
||||
}
|
||||
|
||||
/**
|
||||
* Output of calculateBeadDiff function
|
||||
*/
|
||||
export interface BeadDiffOutput {
|
||||
changes: BeadDiffResult[]
|
||||
highlights: PlaceValueBasedBead[]
|
||||
hasChanges: boolean
|
||||
summary: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the diff between two abacus states
|
||||
* Returns exactly which beads need to move with directions and order
|
||||
* @param fromState - Starting state
|
||||
* @param toState - Target state
|
||||
* @returns BeadDiffOutput with changes, highlights, and summary
|
||||
*/
|
||||
export function calculateBeadDiff(fromState: AbacusState, toState: AbacusState): BeadDiffOutput {
|
||||
const { additions, removals } = calculateBeadChanges(fromState, toState)
|
||||
|
||||
const changes: BeadDiffResult[] = []
|
||||
const highlights: PlaceValueBasedBead[] = []
|
||||
let order = 0
|
||||
|
||||
// Process removals first (pedagogical order: clear before adding)
|
||||
removals.forEach((removal) => {
|
||||
changes.push({
|
||||
placeValue: removal.placeValue,
|
||||
beadType: removal.beadType,
|
||||
position: removal.position,
|
||||
direction: 'deactivate',
|
||||
order: order++,
|
||||
})
|
||||
|
||||
highlights.push({
|
||||
placeValue: removal.placeValue,
|
||||
beadType: removal.beadType,
|
||||
position: removal.position,
|
||||
})
|
||||
})
|
||||
|
||||
// Process additions second (pedagogical order: add after clearing)
|
||||
additions.forEach((addition) => {
|
||||
changes.push({
|
||||
placeValue: addition.placeValue,
|
||||
beadType: addition.beadType,
|
||||
position: addition.position,
|
||||
direction: 'activate',
|
||||
order: order++,
|
||||
})
|
||||
|
||||
highlights.push({
|
||||
placeValue: addition.placeValue,
|
||||
beadType: addition.beadType,
|
||||
position: addition.position,
|
||||
})
|
||||
})
|
||||
|
||||
// Generate summary
|
||||
const summary = generateDiffSummary(changes)
|
||||
|
||||
return {
|
||||
changes,
|
||||
highlights,
|
||||
hasChanges: changes.length > 0,
|
||||
summary,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bead diff from numeric values
|
||||
* Convenience function for when you have numbers instead of states
|
||||
* @param fromValue - Starting numeric value
|
||||
* @param toValue - Target numeric value
|
||||
* @param maxPlaces - Maximum number of place values to consider
|
||||
* @returns BeadDiffOutput
|
||||
*/
|
||||
export function calculateBeadDiffFromValues(
|
||||
fromValue: number | bigint,
|
||||
toValue: number | bigint,
|
||||
maxPlaces: number = 5
|
||||
): BeadDiffOutput {
|
||||
const fromState = numberToAbacusState(fromValue, maxPlaces)
|
||||
const toState = numberToAbacusState(toValue, maxPlaces)
|
||||
return calculateBeadDiff(fromState, toState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an abacus value is within the supported range
|
||||
* @param value - The value to validate
|
||||
* @param maxPlaces - Maximum number of place values supported
|
||||
* @returns Object with isValid boolean and optional error message
|
||||
*/
|
||||
export function validateAbacusValue(
|
||||
value: number | bigint,
|
||||
maxPlaces: number = 5
|
||||
): { isValid: boolean; error?: string } {
|
||||
const valueNum = typeof value === 'bigint' ? Number(value) : value
|
||||
|
||||
if (valueNum < 0) {
|
||||
return { isValid: false, error: 'Negative values are not supported' }
|
||||
}
|
||||
|
||||
const maxValue = 10 ** maxPlaces - 1
|
||||
if (valueNum > maxValue) {
|
||||
return { isValid: false, error: `Value exceeds maximum for ${maxPlaces} columns (max: ${maxValue})` }
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two abacus states are equal
|
||||
* @param state1 - First state
|
||||
* @param state2 - Second state
|
||||
* @returns true if states are equal
|
||||
*/
|
||||
export function areStatesEqual(state1: AbacusState, state2: AbacusState): boolean {
|
||||
const places1 = Object.keys(state1)
|
||||
.map((k) => parseInt(k, 10))
|
||||
.sort()
|
||||
const places2 = Object.keys(state2)
|
||||
.map((k) => parseInt(k, 10))
|
||||
.sort()
|
||||
|
||||
if (places1.length !== places2.length) return false
|
||||
|
||||
for (const place of places1) {
|
||||
const bead1 = state1[place]
|
||||
const bead2 = state2[place]
|
||||
|
||||
if (!bead2) return false
|
||||
if (bead1.heavenActive !== bead2.heavenActive) return false
|
||||
if (bead1.earthActive !== bead2.earthActive) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Internal helper functions
|
||||
|
||||
function generateDiffSummary(changes: BeadDiffResult[]): string {
|
||||
if (changes.length === 0) {
|
||||
return 'No changes needed'
|
||||
}
|
||||
|
||||
// Sort by order to respect pedagogical sequence
|
||||
const sortedChanges = [...changes].sort((a, b) => a.order - b.order)
|
||||
|
||||
const deactivations = sortedChanges.filter((c) => c.direction === 'deactivate')
|
||||
const activations = sortedChanges.filter((c) => c.direction === 'activate')
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
// Process deactivations first (pedagogical order)
|
||||
if (deactivations.length > 0) {
|
||||
const deactivationsByPlace = groupByPlace(deactivations)
|
||||
Object.entries(deactivationsByPlace).forEach(([place, beads]) => {
|
||||
const placeName = getPlaceName(parseInt(place, 10))
|
||||
const heavenBeads = beads.filter((b) => b.beadType === 'heaven')
|
||||
const earthBeads = beads.filter((b) => b.beadType === 'earth')
|
||||
|
||||
if (heavenBeads.length > 0) {
|
||||
parts.push(`remove heaven bead in ${placeName}`)
|
||||
}
|
||||
if (earthBeads.length > 0) {
|
||||
const count = earthBeads.length
|
||||
parts.push(`remove ${count} earth bead${count > 1 ? 's' : ''} in ${placeName}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Process activations second (pedagogical order)
|
||||
if (activations.length > 0) {
|
||||
const activationsByPlace = groupByPlace(activations)
|
||||
Object.entries(activationsByPlace).forEach(([place, beads]) => {
|
||||
const placeName = getPlaceName(parseInt(place, 10))
|
||||
const heavenBeads = beads.filter((b) => b.beadType === 'heaven')
|
||||
const earthBeads = beads.filter((b) => b.beadType === 'earth')
|
||||
|
||||
if (heavenBeads.length > 0) {
|
||||
parts.push(`add heaven bead in ${placeName}`)
|
||||
}
|
||||
if (earthBeads.length > 0) {
|
||||
const count = earthBeads.length
|
||||
parts.push(`add ${count} earth bead${count > 1 ? 's' : ''} in ${placeName}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return parts.join(', then ')
|
||||
}
|
||||
|
||||
function groupByPlace(changes: BeadDiffResult[]): {
|
||||
[place: string]: BeadDiffResult[]
|
||||
} {
|
||||
return changes.reduce(
|
||||
(groups, change) => {
|
||||
const place = change.placeValue.toString()
|
||||
if (!groups[place]) {
|
||||
groups[place] = []
|
||||
}
|
||||
groups[place].push(change)
|
||||
return groups
|
||||
},
|
||||
{} as { [place: string]: BeadDiffResult[] }
|
||||
)
|
||||
}
|
||||
|
||||
function getPlaceName(place: number): string {
|
||||
switch (place) {
|
||||
case 0:
|
||||
return 'ones column'
|
||||
case 1:
|
||||
return 'tens column'
|
||||
case 2:
|
||||
return 'hundreds column'
|
||||
case 3:
|
||||
return 'thousands column'
|
||||
default:
|
||||
return `place ${place} column`
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user