From e65541c100e590a51448750c6d5178ed4f3e8eeb Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Mon, 3 Nov 2025 17:07:29 -0600 Subject: [PATCH] feat(abacus-react): add core utility functions for state management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- packages/abacus-react/src/AbacusUtils.ts | 358 +++++++++++++++++++++++ 1 file changed, 358 insertions(+) create mode 100644 packages/abacus-react/src/AbacusUtils.ts diff --git a/packages/abacus-react/src/AbacusUtils.ts b/packages/abacus-react/src/AbacusUtils.ts new file mode 100644 index 00000000..28727ce9 --- /dev/null +++ b/packages/abacus-react/src/AbacusUtils.ts @@ -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` + } +}