"use client"; 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"; import { useAbacusConfig, getDefaultAbacusConfig } from "./AbacusContext"; import { playBeadSound } from "./soundManager"; import * as Abacus3DUtils from "./Abacus3DUtils"; import "./Abacus3D.css"; // Types export interface BeadConfig { type: "heaven" | "earth"; value: number; active: boolean; position: number; // 0-based position within its type group placeValue: ValidPlaceValues; // 0=ones, 1=tens, 2=hundreds, etc. - NATIVE place-value architecture! } // Comprehensive styling system export interface BeadStyle { fill?: string; stroke?: string; strokeWidth?: number; opacity?: number; cursor?: string; className?: string; } export interface ColumnPostStyle { fill?: string; stroke?: string; strokeWidth?: number; opacity?: number; className?: string; } export interface ReckoningBarStyle { fill?: string; stroke?: string; strokeWidth?: number; opacity?: number; className?: string; } export interface NumeralStyle { color?: string; backgroundColor?: string; fontSize?: string; fontFamily?: string; fontWeight?: string; borderColor?: string; borderWidth?: number; borderRadius?: number; className?: string; } export interface BackgroundGlowStyle { fill?: string; blur?: number; spread?: number; opacity?: number; } export interface AbacusCustomStyles { // Global defaults heavenBeads?: BeadStyle; earthBeads?: BeadStyle; activeBeads?: BeadStyle; inactiveBeads?: BeadStyle; columnPosts?: ColumnPostStyle; reckoningBar?: ReckoningBarStyle; numerals?: NumeralStyle; numeralContainers?: NumeralStyle; // Column-specific overrides (by column index) columns?: { [columnIndex: number]: { heavenBeads?: BeadStyle; earthBeads?: BeadStyle; activeBeads?: BeadStyle; inactiveBeads?: BeadStyle; columnPost?: ColumnPostStyle; numerals?: NumeralStyle; numeralContainer?: NumeralStyle; backgroundGlow?: BackgroundGlowStyle; }; }; // Individual bead overrides (by column and bead position) beads?: { [columnIndex: number]: { heaven?: BeadStyle; earth?: { [position: number]: BeadStyle; // position 0-3 for earth beads }; }; }; } // Branded types to prevent mixing place values and column indices export type PlaceValue = number & { readonly __brand: "PlaceValue" }; export type ColumnIndex = number & { readonly __brand: "ColumnIndex" }; // Type-safe constructors export const PlaceValue = (value: number): PlaceValue => { if (value < 0) { throw new Error(`Place value must be non-negative, got ${value}`); } return value as PlaceValue; }; export const ColumnIndex = (value: number): ColumnIndex => { if (value < 0) { throw new Error(`Column index must be non-negative, got ${value}`); } return value as ColumnIndex; }; // Utility types for better type safety export type ValidPlaceValues = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; export type EarthBeadPosition = 0 | 1 | 2 | 3; // Place-value based bead specification (new API) export interface PlaceValueBead { placeValue: ValidPlaceValues; // 0=ones, 1=tens, 2=hundreds, etc. beadType: "heaven" | "earth"; position?: EarthBeadPosition; // for earth beads, 0-3 } // Legacy column-index based bead specification export interface ColumnIndexBead { columnIndex: number; // array index (0=leftmost) beadType: "heaven" | "earth"; position?: EarthBeadPosition; // for earth beads, 0-3 } // Type-safe conversion utilities export namespace PlaceValueUtils { export function toColumnIndex( placeValue: ValidPlaceValues, totalColumns: number, ): number { const result = totalColumns - 1 - placeValue; if (result < 0 || result >= totalColumns) { throw new Error( `Place value ${placeValue} is out of range for ${totalColumns} columns`, ); } return result; } export function fromColumnIndex( columnIndex: number, totalColumns: number, ): ValidPlaceValues { const result = totalColumns - 1 - columnIndex; if (result < 0 || result > 9) { throw new Error( `Column index ${columnIndex} maps to invalid place value ${result}`, ); } return result as ValidPlaceValues; } // Type-safe creation helpers export const ones = (): PlaceValueBead["placeValue"] => 0; export const tens = (): PlaceValueBead["placeValue"] => 1; export const hundreds = (): PlaceValueBead["placeValue"] => 2; export const thousands = (): PlaceValueBead["placeValue"] => 3; } // Union type for backward compatibility export type BeadHighlight = PlaceValueBead | ColumnIndexBead; // Enhanced bead highlight with step progression and direction indicators export interface StepBeadHighlight extends PlaceValueBead { stepIndex: number; // Which instruction step this bead belongs to direction: "up" | "down" | "activate" | "deactivate"; // Movement direction order?: number; // Order within the step (for multiple beads per step) } // Type guards to distinguish between the two APIs export function isPlaceValueBead(bead: BeadHighlight): bead is PlaceValueBead { return "placeValue" in bead; } export function isColumnIndexBead( bead: BeadHighlight, ): bead is ColumnIndexBead { return "columnIndex" in bead; } // Event system export interface BeadClickEvent { bead: BeadConfig; columnIndex: number; beadType: "heaven" | "earth"; position: number; active: boolean; value: number; event: React.MouseEvent; } export interface AbacusCallbacks { onBeadClick?: (event: BeadClickEvent) => void; onBeadHover?: (event: BeadClickEvent) => void; onBeadLeave?: (event: BeadClickEvent) => void; onColumnClick?: (columnIndex: number, event: React.MouseEvent) => void; onColumnHover?: (columnIndex: number, event: React.MouseEvent) => void; onColumnLeave?: (columnIndex: number, event: React.MouseEvent) => void; onNumeralClick?: ( columnIndex: number, value: number, event: React.MouseEvent, ) => void; onValueChange?: (newValue: number | bigint) => void; onBeadRef?: (bead: BeadConfig, element: SVGElement | null) => void; // Legacy callback for backward compatibility onClick?: (bead: BeadConfig) => void; } // Overlay and injection system export interface AbacusOverlay { id: string; type: "tooltip" | "arrow" | "highlight" | "custom"; target: { type: "bead" | "column" | "numeral" | "bar" | "coordinates"; columnIndex?: number; beadType?: "heaven" | "earth"; beadPosition?: number; // for earth beads x?: number; // for coordinate-based positioning y?: number; }; content: React.ReactNode; style?: React.CSSProperties; className?: string; offset?: { x: number; y: number }; visible?: boolean; } // 3D Enhancement Configuration export type BeadMaterial = "glossy" | "satin" | "matte"; export type FrameMaterial = "wood" | "metal" | "minimal"; export type LightingStyle = "top-down" | "ambient" | "dramatic"; export interface Abacus3DMaterial { heavenBeads?: BeadMaterial; earthBeads?: BeadMaterial; frame?: FrameMaterial; lighting?: LightingStyle; woodGrain?: boolean; // Add wood texture to frame } export interface Abacus3DPhysics { wobble?: boolean; // Beads rotate slightly during movement clackEffect?: boolean; // Visual ripple when beads snap hoverParallax?: boolean; // Beads lift on hover particleSnap?: "off" | "subtle" | "sparkle"; // Particle effects on snap hapticFeedback?: boolean; // Trigger haptic feedback on mobile } export interface AbacusConfig { // Basic configuration value?: number | bigint; 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; interactive?: boolean; gestures?: boolean; showNumbers?: boolean; soundEnabled?: boolean; soundVolume?: number; // 3D Enhancement enhanced3d?: boolean | "subtle" | "realistic" | "delightful"; material3d?: Abacus3DMaterial; physics3d?: Abacus3DPhysics; // Advanced customization customStyles?: AbacusCustomStyles; callbacks?: AbacusCallbacks; overlays?: AbacusOverlay[]; // Tutorial and accessibility features highlightColumns?: number[]; // Highlight specific columns (legacy - array indices) highlightBeads?: BeadHighlight[]; // Support both place-value and column-index based highlighting stepBeadHighlights?: StepBeadHighlight[]; // Progressive step-based highlighting with directions currentStep?: number; // Current step index for progressive highlighting showDirectionIndicators?: boolean; // Show direction arrows/indicators on beads disabledColumns?: number[]; // Disable interaction on specific columns (legacy - array indices) disabledBeads?: BeadHighlight[]; // Support both place-value and column-index based disabling // Legacy callbacks for backward compatibility onClick?: (bead: BeadConfig) => void; onValueChange?: (newValue: number | bigint) => 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: boolean = false, ): 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 are visible const numbersSpace = 40 * scaleFactor; // Space for NumberFlow components const totalHeight = showNumbers ? baseHeight + numbersSpace : baseHeight; return { width: totalWidth, height: totalHeight, rodSpacing: columnSpacing, beadSize, rodWidth, barThickness, heavenEarthGap, activeGap, inactiveGap, adjacentSpacing, }; }, [columns, scaleFactor, showNumbers]); } // Legacy column state interface (deprecated) interface ColumnState { heavenActive: boolean; // true if heaven bead (value 5) is active earthActive: number; // 0-4, number of active earth beads } // Native place-value state (no more array indices!) export interface PlaceState { placeValue: ValidPlaceValues; heavenActive: boolean; earthActive: number; // 0-4, number of active earth beads } // State map keyed by place value - this eliminates the indexing nightmare! export type PlaceStatesMap = Map; /** * @deprecated Use useAbacusPlaceStates() instead. * This hook uses array-based column indexing which requires totalColumns threading. * The new hook uses Map-based place values for cleaner architecture. */ export function useAbacusState( initialValue: number = 0, targetColumns?: number, ) { // Initialize state from the initial value const initializeFromValue = useCallback( (value: number, minColumns?: number): ColumnState[] => { if (value === 0) { // Special case: for value 0, use minColumns if provided, otherwise single column const columnCount = minColumns || 1; return Array(columnCount) .fill(null) .map(() => ({ heavenActive: false, earthActive: 0 })); } const digits = value.toString().split("").map(Number); const result = digits.map((digit) => ({ heavenActive: digit >= 5, earthActive: digit % 5, })); // Ensure we have at least minColumns if specified if (minColumns && result.length < minColumns) { const paddingNeeded = minColumns - result.length; const padding = Array(paddingNeeded) .fill(null) .map(() => ({ heavenActive: false, earthActive: 0 })); return [...padding, ...result]; } return result; }, [targetColumns], ); const [columnStates, setColumnStates] = useState(() => initializeFromValue(initialValue, targetColumns), ); // 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, targetColumns)); }, [initializeFromValue, targetColumns], ); 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) => { // Convert place value to column index for this legacy hook const placeIndex = columnStates.length - 1 - bead.placeValue; const currentState = getColumnState(placeIndex); if (bead.type === "heaven") { // Toggle heaven bead independently setColumnState(placeIndex, { ...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(placeIndex, { ...currentState, earthActive: Math.min(currentState.earthActive, bead.position), }); } else { // Activate this bead and all lower positioned earth beads setColumnState(placeIndex, { ...currentState, earthActive: Math.max(currentState.earthActive, bead.position + 1), }); } } }, [getColumnState, setColumnState, columnStates.length], ); return { value, setValue, columnStates, getColumnState, setColumnState, toggleBead, }; } // NEW: Native place-value state management hook (eliminates the column index nightmare!) export function useAbacusPlaceStates( controlledValue: number | bigint = 0, maxPlaceValue: ValidPlaceValues = 4, ) { // Initialize state from value using place values as keys - NO MORE ARRAY INDICES! const initializeFromValue = useCallback( (value: number | bigint): PlaceStatesMap => { const states = new Map(); // Convert to string to handle both number and bigint const valueStr = value.toString(); const digits = valueStr.split('').map(Number); // Always create ALL place values from 0 to maxPlaceValue (to match columns) for (let place = 0; place <= maxPlaceValue; place++) { // Get digit from right: place 0 = rightmost, place 1 = second from right, etc. const digitIndex = digits.length - 1 - place; const digit = digitIndex >= 0 ? digits[digitIndex] : 0; states.set(place as ValidPlaceValues, { placeValue: place as ValidPlaceValues, heavenActive: digit >= 5, earthActive: digit >= 5 ? digit - 5 : digit, }); } return states; }, [maxPlaceValue], ); const [placeStates, setPlaceStates] = useState(() => initializeFromValue(controlledValue), ); // Calculate current value from place states - NO MORE INDEX MATH! // Use BigInt for numbers that exceed safe integer range (>15 digits) const value = useMemo(() => { // Check if we need BigInt (maxPlaceValue > 14 means >15 digits) const useBigInt = maxPlaceValue > 14; if (useBigInt) { let total = 0n; placeStates.forEach((state) => { const placeValueNum = 10n ** BigInt(state.placeValue); const digitValue = BigInt((state.heavenActive ? 5 : 0) + state.earthActive); total += digitValue * placeValueNum; }); return total; } else { let total = 0; placeStates.forEach((state) => { const placeValueNum = Math.pow(10, state.placeValue); const digitValue = (state.heavenActive ? 5 : 0) + state.earthActive; total += digitValue * placeValueNum; }); return total; } }, [placeStates, maxPlaceValue]); const setValue = useCallback( (newValue: number | bigint) => { setPlaceStates(initializeFromValue(newValue)); }, [initializeFromValue], ); // Update internal state when external controlled value changes // Only update if the controlled value is different from our current value // This prevents infinite loops while allowing controlled updates React.useEffect(() => { const currentInternalValue = value; if (controlledValue !== currentInternalValue) { setPlaceStates(initializeFromValue(controlledValue)); } }, [controlledValue, initializeFromValue, value]); // Clean up place states when maxPlaceValue decreases (columns decrease) // This prevents stale place values from causing out-of-bounds access React.useEffect(() => { setPlaceStates((prev) => { const newStates = new Map(prev); let hasChanges = false; // Remove any place values greater than maxPlaceValue for (const placeValue of newStates.keys()) { if (placeValue > maxPlaceValue) { newStates.delete(placeValue); hasChanges = true; } } // Add missing place values up to maxPlaceValue for (let place = 0; place <= maxPlaceValue; place++) { if (!newStates.has(place as ValidPlaceValues)) { newStates.set(place as ValidPlaceValues, { placeValue: place as ValidPlaceValues, heavenActive: false, earthActive: 0, }); hasChanges = true; } } return hasChanges ? newStates : prev; }); }, [maxPlaceValue]); const getPlaceState = useCallback( (placeValue: ValidPlaceValues): PlaceState => { return ( placeStates.get(placeValue) || { placeValue, heavenActive: false, earthActive: 0, } ); }, [placeStates], ); const setPlaceState = useCallback( ( placeValue: ValidPlaceValues, newState: Omit, ) => { setPlaceStates((prev) => { const newStates = new Map(prev); newStates.set(placeValue, { placeValue, ...newState }); return newStates; }); }, [], ); const toggleBead = useCallback( (bead: BeadConfig) => { const currentState = getPlaceState(bead.placeValue); if (bead.type === "heaven") { setPlaceState(bead.placeValue, { ...currentState, heavenActive: !currentState.heavenActive, }); } else { // Earth bead toggle logic - same as legacy but cleaner if (bead.active) { // Deactivate this bead and all higher positioned earth beads setPlaceState(bead.placeValue, { ...currentState, earthActive: Math.min(currentState.earthActive, bead.position), }); } else { // Activate this bead and all lower positioned earth beads setPlaceState(bead.placeValue, { ...currentState, earthActive: Math.max(currentState.earthActive, bead.position + 1), }); } } }, [getPlaceState, setPlaceState], ); return { value, setValue, placeStates, getPlaceState, setPlaceState, toggleBead, }; } // Utility functions for customization system function mergeBeadStyles( baseStyle: BeadStyle, customStyles?: AbacusCustomStyles, columnIndex?: number, beadType?: "heaven" | "earth", position?: number, isActive?: boolean, ): BeadStyle { let mergedStyle = { ...baseStyle }; // Apply global bead type styles if (customStyles?.heavenBeads && beadType === "heaven") { mergedStyle = { ...mergedStyle, ...customStyles.heavenBeads }; } if (customStyles?.earthBeads && beadType === "earth") { mergedStyle = { ...mergedStyle, ...customStyles.earthBeads }; } // Apply active/inactive styles if (isActive && customStyles?.activeBeads) { mergedStyle = { ...mergedStyle, ...customStyles.activeBeads }; } if (!isActive && customStyles?.inactiveBeads) { mergedStyle = { ...mergedStyle, ...customStyles.inactiveBeads }; } // Apply column-specific styles if (columnIndex !== undefined && customStyles?.columns?.[columnIndex]) { const columnStyles = customStyles.columns[columnIndex]; if (columnStyles.heavenBeads && beadType === "heaven") { mergedStyle = { ...mergedStyle, ...columnStyles.heavenBeads }; } if (columnStyles.earthBeads && beadType === "earth") { mergedStyle = { ...mergedStyle, ...columnStyles.earthBeads }; } if (isActive && columnStyles.activeBeads) { mergedStyle = { ...mergedStyle, ...columnStyles.activeBeads }; } if (!isActive && columnStyles.inactiveBeads) { mergedStyle = { ...mergedStyle, ...columnStyles.inactiveBeads }; } } // Apply individual bead styles (highest specificity) if (columnIndex !== undefined && customStyles?.beads?.[columnIndex]) { const beadStyles = customStyles.beads[columnIndex]; if (beadType === "heaven" && beadStyles.heaven) { mergedStyle = { ...mergedStyle, ...beadStyles.heaven }; } if ( beadType === "earth" && position !== undefined && beadStyles.earth?.[position] ) { mergedStyle = { ...mergedStyle, ...beadStyles.earth[position] }; } } return mergedStyle; } // REMOVED: normalizeBeadHighlight function - no longer needed with place-value architecture! /** * @deprecated Use isBeadHighlightedByPlaceValue() instead. * This function requires totalColumns threading which the new architecture eliminates. */ function isBeadHighlighted( columnIndex: number, beadType: "heaven" | "earth", position: number | undefined, highlightBeads?: BeadHighlight[], totalColumns?: number, ): boolean { if (!highlightBeads || !totalColumns) return false; return highlightBeads.some((highlight) => { // Convert column index to place value for pure place-value API const targetPlaceValue = totalColumns - 1 - columnIndex; if ("placeValue" in highlight) { return ( highlight.placeValue === targetPlaceValue && highlight.beadType === beadType && (highlight.position === undefined || highlight.position === position) ); } else { return ( highlight.columnIndex === columnIndex && highlight.beadType === beadType && (highlight.position === undefined || highlight.position === position) ); } }); } /** * @deprecated Use isBeadDisabledByPlaceValue() instead. * This function requires totalColumns threading which the new architecture eliminates. */ function isBeadDisabled( columnIndex: number, beadType: "heaven" | "earth", position: number | undefined, disabledColumns?: number[], disabledBeads?: BeadHighlight[], totalColumns?: number, ): boolean { // Check if entire column is disabled (legacy column index system) if (disabledColumns?.includes(columnIndex)) { return true; } // Check if specific bead is disabled if (!disabledBeads || !totalColumns) return false; return disabledBeads.some((disabled) => { // Convert column index to place value for pure place-value API const targetPlaceValue = totalColumns - 1 - columnIndex; if ("placeValue" in disabled) { return ( disabled.placeValue === targetPlaceValue && disabled.beadType === beadType && (disabled.position === undefined || disabled.position === position) ); } else { return ( disabled.columnIndex === columnIndex && disabled.beadType === beadType && (disabled.position === undefined || disabled.position === position) ); } }); } // NEW: Native place-value highlighting (eliminates totalColumns threading!) function isBeadHighlightedByPlaceValue( bead: BeadConfig, highlightBeads?: BeadHighlight[], ): boolean { if (!highlightBeads) return false; return highlightBeads.some((highlight) => { // Direct place value matching - NO MORE CONVERSION NEEDED! if ("placeValue" in highlight) { return ( highlight.placeValue === bead.placeValue && highlight.beadType === bead.type && (highlight.position === undefined || highlight.position === bead.position) ); } // Legacy columnIndex support - convert to place value for comparison if ("columnIndex" in highlight) { // We need to know total columns to convert - for now, warn about legacy usage console.warn( "Legacy columnIndex highlighting detected - migrate to placeValue API for better performance", ); return false; // Cannot properly support without totalColumns threading } return false; }); } // NEW: Step-based highlighting with progressive revelation function getBeadStepHighlight( bead: BeadConfig, stepBeadHighlights?: StepBeadHighlight[], currentStep?: number, ): { isHighlighted: boolean; direction?: string; isCurrentStep: boolean } { if (!stepBeadHighlights || currentStep === undefined) { return { isHighlighted: false, isCurrentStep: false }; } const matchingStepBead = stepBeadHighlights.find((stepBead) => { const matches = stepBead.placeValue === bead.placeValue && stepBead.beadType === bead.type && (stepBead.position === undefined || stepBead.position === bead.position); return matches; }); if (!matchingStepBead) { return { isHighlighted: false, isCurrentStep: false }; } const isCurrentStep = matchingStepBead.stepIndex === currentStep; const isCompleted = matchingStepBead.stepIndex < currentStep; const isHighlighted = isCurrentStep || isCompleted; return { isHighlighted, direction: isCurrentStep ? matchingStepBead.direction : undefined, isCurrentStep, }; } // NEW: Native place-value disabling (eliminates totalColumns threading!) function isBeadDisabledByPlaceValue( bead: BeadConfig, disabledBeads?: BeadHighlight[], ): boolean { if (!disabledBeads) return false; return disabledBeads.some((disabled) => { // Direct place value matching - NO MORE CONVERSION NEEDED! if ("placeValue" in disabled) { return ( disabled.placeValue === bead.placeValue && disabled.beadType === bead.type && (disabled.position === undefined || disabled.position === bead.position) ); } // Legacy columnIndex support - convert to place value for comparison if ("columnIndex" in disabled) { // We need to know total columns to convert - for now, warn about legacy usage console.warn( "Legacy columnIndex disabling detected - migrate to placeValue API for better performance", ); return false; // Cannot properly support without totalColumns threading } return false; }); } function calculateOverlayPosition( overlay: AbacusOverlay, dimensions: AbacusDimensions, columnIndex?: number, beadPosition?: { x: number; y: number }, ): { x: number; y: number } { let x = 0; let y = 0; switch (overlay.target.type) { case "coordinates": x = overlay.target.x || 0; y = overlay.target.y || 0; break; case "bead": if (beadPosition) { x = beadPosition.x; y = beadPosition.y; } break; case "column": if (overlay.target.columnIndex !== undefined) { x = overlay.target.columnIndex * dimensions.rodSpacing + dimensions.rodSpacing / 2; y = dimensions.height / 2; } break; case "numeral": if (overlay.target.columnIndex !== undefined) { x = overlay.target.columnIndex * dimensions.rodSpacing + dimensions.rodSpacing / 2; const baseHeight = dimensions.heavenEarthGap + 5 * (dimensions.beadSize + 4) + 10; y = baseHeight + 25; } break; case "bar": x = dimensions.width / 2; y = dimensions.heavenEarthGap; break; } // Apply offset if (overlay.offset) { x += overlay.offset.x; y += overlay.offset.y; } return { x, y }; } // 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, isHighlighted: boolean = false, ): string { const inactiveColor = "rgb(211, 211, 211)"; // Typst uses gray.lighten(70%) const highlightColor = "#FFD700"; // Gold color for highlighting // If highlighted, return the highlight color regardless of active state if (isHighlighted) return highlightColor; if (!bead.active) return inactiveColor; switch (colorScheme) { case "place-value": { const colors = COLOR_PALETTES[colorPalette as keyof typeof COLOR_PALETTES] || COLOR_PALETTES.default; return colors[bead.placeValue % colors.length]; } case "alternating": return bead.placeValue % 2 === 0 ? "#1E88E5" : "#43A047"; case "heaven-earth": return bead.type === "heaven" ? "#F18F01" : "#2E86AB"; // Exact Typst colors (lines 228, 265) default: return "#000000"; } } // Get arrow colors that respect color schemes and accessibility function getArrowColors( bead: BeadConfig, direction: string, totalColumns: number, colorScheme: string, colorPalette: string, ): { fill: string; stroke: string } { const isActivating = direction === "activate" || direction === "up"; switch (colorScheme) { case "monochrome": return isActivating ? { fill: "rgba(100, 100, 100, 0.8)", stroke: "rgba(50, 50, 50, 1)" } : { fill: "rgba(150, 150, 150, 0.8)", stroke: "rgba(100, 100, 100, 1)", }; case "grayscale": return isActivating ? { fill: "rgba(60, 60, 60, 0.8)", stroke: "rgba(30, 30, 30, 1)" } : { fill: "rgba(120, 120, 120, 0.8)", stroke: "rgba(80, 80, 80, 1)" }; case "place-value": { const colors = COLOR_PALETTES[colorPalette as keyof typeof COLOR_PALETTES] || COLOR_PALETTES.default; const baseColor = colors[bead.placeValue % colors.length]; // Create darker/lighter variants for arrows const activateColor = isActivating ? baseColor : adjustColorBrightness(baseColor, -30); const strokeColor = adjustColorBrightness(activateColor, -40); return { fill: `${activateColor}CC`, // Add alpha stroke: strokeColor, }; } case "heaven-earth": { const baseColor = bead.type === "heaven" ? "#F18F01" : "#2E86AB"; const activateColor = isActivating ? baseColor : adjustColorBrightness(baseColor, -30); const strokeColor = adjustColorBrightness(activateColor, -40); return { fill: `${activateColor}CC`, stroke: strokeColor, }; } case "alternating": { const baseColor = bead.placeValue % 2 === 0 ? "#1E88E5" : "#43A047"; const activateColor = isActivating ? baseColor : adjustColorBrightness(baseColor, -30); const strokeColor = adjustColorBrightness(activateColor, -40); return { fill: `${activateColor}CC`, stroke: strokeColor, }; } default: // Fallback to original green/red system return isActivating ? { fill: "rgba(0, 150, 0, 0.8)", stroke: "rgba(0, 100, 0, 1)" } : { fill: "rgba(200, 0, 0, 0.8)", stroke: "rgba(150, 0, 0, 1)" }; } } // Helper function to adjust color brightness function adjustColorBrightness(hex: string, percent: number): string { // Remove # if present hex = hex.replace("#", ""); // Parse RGB components const r = parseInt(hex.substr(0, 2), 16); const g = parseInt(hex.substr(2, 2), 16); const b = parseInt(hex.substr(4, 2), 16); // Adjust brightness const newR = Math.max(0, Math.min(255, r + (r * percent) / 100)); const newG = Math.max(0, Math.min(255, g + (g * percent) / 100)); const newB = Math.max(0, Math.min(255, b + (b * percent) / 100)); // Convert back to hex return `#${Math.round(newR).toString(16).padStart(2, "0")}${Math.round(newG).toString(16).padStart(2, "0")}${Math.round(newB).toString(16).padStart(2, "0")}`; } function calculateBeadStates( columnStates: ColumnState[], originalLength: number, ): BeadConfig[][] { return columnStates.map((columnState, arrayIndex) => { const beads: BeadConfig[] = []; // Convert array index to place value: leftmost = highest place value const placeValue = (columnStates.length - 1 - arrayIndex) as ValidPlaceValues; // Heaven bead (value 5) - independent state beads.push({ type: "heaven", value: 5, active: columnState.heavenActive, position: 0, placeValue: placeValue, }); // 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, placeValue: placeValue, }); } return beads; }); } // NEW: Native place-value bead state calculation (eliminates array index math!) function calculateBeadStatesFromPlaces( placeStates: PlaceStatesMap, maxPlaceValue: ValidPlaceValues, ): BeadConfig[][] { const columnsList: BeadConfig[][] = []; // Convert Map to sorted array by place value (ascending order for correct visual layout) // Filter to only include place values that are within the current column count const sortedPlaces = Array.from(placeStates.entries()) .filter(([placeValue]) => placeValue <= maxPlaceValue) .sort(([a], [b]) => a - b); for (const [placeValue, placeState] of sortedPlaces) { const beads: BeadConfig[] = []; // Heaven bead (value 5) - independent state beads.push({ type: "heaven", value: 5, active: placeState.heavenActive, position: 0, placeValue: placeValue, // Direct place value - no conversion needed! }); // Earth beads (4 beads, each value 1) - independent state for (let i = 0; i < 4; i++) { beads.push({ type: "earth", value: 1, active: i < placeState.earthActive, position: i, placeValue: placeValue, // Direct place value - no conversion needed! }); } columnsList.push(beads); } // Return in visual order (left-to-right = highest-to-lowest place value) return columnsList.reverse(); } // 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; } // NEW: Native place-value calculation (eliminates the array index nightmare!) function calculateValueFromPlaceStates(placeStates: PlaceStatesMap): number | bigint { // Determine if we need BigInt based on the largest place value const maxPlace = Math.max(...Array.from(placeStates.keys())); const useBigInt = maxPlace > 14; // >15 digits if (useBigInt) { let value = 0n; for (const [placeValue, placeState] of placeStates) { const digitValue = BigInt((placeState.heavenActive ? 5 : 0) + placeState.earthActive); value += digitValue * (10n ** BigInt(placeValue)); } return value; } else { let value = 0; for (const [placeValue, placeState] of placeStates) { const digitValue = (placeState.heavenActive ? 5 : 0) + placeState.earthActive; value += digitValue * Math.pow(10, placeValue); } return value; } } // Components interface BeadProps { bead: BeadConfig; x: number; y: number; size: number; shape: "diamond" | "square" | "circle"; color: string; customStyle?: BeadStyle; isHighlighted?: boolean; isDisabled?: boolean; enableAnimation: boolean; enableGestures?: boolean; hideInactiveBeads?: boolean; showDirectionIndicator?: boolean; direction?: string; isCurrentStep?: boolean; onClick?: (event: React.MouseEvent) => void; onHover?: (event: React.MouseEvent) => void; onLeave?: (event: React.MouseEvent) => void; onGestureToggle?: ( bead: BeadConfig, direction: "activate" | "deactivate", ) => void; onRef?: (element: SVGElement | null) => void; heavenEarthGap: number; barY: number; // Arrow color scheme integration colorScheme?: string; colorPalette?: string; totalColumns?: number; // 3D Enhancement enhanced3d?: boolean | "subtle" | "realistic" | "delightful"; material3d?: Abacus3DMaterial; physics3d?: Abacus3DPhysics; columnIndex?: number; mousePosition?: { x: number; y: number }; containerBounds?: { x: number; y: number }; } const Bead: React.FC = ({ bead, x, y, size, shape, color, customStyle, isHighlighted = false, isDisabled = false, enableAnimation, enableGestures = false, hideInactiveBeads = false, showDirectionIndicator = false, direction, isCurrentStep = false, onClick, onHover, onLeave, onGestureToggle, onRef, heavenEarthGap, barY, colorScheme = "monochrome", colorPalette = "default", totalColumns = 1, enhanced3d, material3d, physics3d, columnIndex, mousePosition, containerBounds, }) => { // Detect server-side rendering const isServer = typeof window === 'undefined'; // Use springs only if not on server and animations are enabled // Enhanced physics config for 3D modes const physicsConfig = React.useMemo(() => { if (!enableAnimation || isServer) return { duration: 0 }; if (!enhanced3d || enhanced3d === true || enhanced3d === 'subtle') return config.default; return Abacus3DUtils.getPhysicsConfig(enhanced3d); }, [enableAnimation, isServer, enhanced3d]); const [{ x: springX, y: springY }, api] = useSpring(() => ({ x, y, config: physicsConfig })); // Track velocity for wobble effect (delightful mode only) const velocityRef = useRef(0); const lastYRef = useRef(y); // Calculate parallax offset for hover effect const parallaxOffset = React.useMemo(() => { if (enhanced3d === 'delightful' && physics3d?.hoverParallax && mousePosition && containerBounds) { return Abacus3DUtils.calculateParallaxOffset( x, y, mousePosition.x, mousePosition.y, containerBounds.x, containerBounds.y, 0.5 ); } return { x: 0, y: 0, z: 0 }; }, [enhanced3d, physics3d?.hoverParallax, mousePosition, containerBounds, x, y]); // Arrow pulse animation for urgency indication const [{ arrowPulse }, arrowApi] = useSpring(() => ({ arrowPulse: 1, config: enableAnimation && !isServer ? { tension: 200, friction: 10 } : { duration: 0 }, })); 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 - only on client with gestures enabled const bind = (enableGestures && !isServer) ? 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) { // Calculate velocity for wobble effect const deltaY = y - lastYRef.current; velocityRef.current = deltaY; lastYRef.current = y; api.start({ x, y, config: physicsConfig }); } else { api.set({ x, y }); } }, [x, y, enableAnimation, api, physicsConfig]); // Pulse animation for direction arrows to indicate urgency React.useEffect(() => { if (showDirectionIndicator && direction && isCurrentStep) { const startPulse = () => { arrowApi.start({ from: { arrowPulse: 1 }, to: async (next) => { await next({ arrowPulse: 1.3 }); await next({ arrowPulse: 1 }); }, loop: true, }); }; const timeoutId = setTimeout(startPulse, 200); // Small delay before starting pulse return () => { clearTimeout(timeoutId); arrowApi.stop(); }; } else { arrowApi.set({ arrowPulse: 1 }); } }, [showDirectionIndicator, direction, isCurrentStep, arrowApi]); const renderShape = () => { const halfSize = size / 2; // Determine fill - use gradient for realistic/delightful modes, otherwise use color let fillValue = color; if ((enhanced3d === 'realistic' || enhanced3d === 'delightful') && columnIndex !== undefined) { if (bead.type === 'heaven') { fillValue = `url(#bead-gradient-${columnIndex}-heaven)`; } else { fillValue = `url(#bead-gradient-${columnIndex}-earth-${bead.position})`; } } switch (shape) { case "diamond": return ( ); case "square": return ( ); case "circle": default: return ( ); } }; // Use animated.g only if animations are enabled, otherwise use regular g const GElement = enableAnimation ? animated.g : 'g'; const DirectionIndicatorG = (enableAnimation && showDirectionIndicator && direction) ? animated.g : '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 }; // Calculate static transform for direction indicator const getDirectionIndicatorTransform = () => { const centerX = shape === "diamond" ? size * 0.7 : size / 2; const centerY = size / 2; const pulse = enableAnimation ? undefined : 1; return `translate(${centerX}, ${centerY}) scale(${pulse})`; }; // Build style object based on animation mode const wobbleEnabled = enhanced3d === 'delightful' && physics3d?.wobble; const parallaxEnabled = enhanced3d === 'delightful' && physics3d?.hoverParallax; const beadStyle: any = enableAnimation ? { transform: to( [springX, springY], (sx, sy) => { const translate = `translate(${sx - getXOffset() + parallaxOffset.x}px, ${sy - getYOffset() + parallaxOffset.y}px)`; const transforms = [translate]; // Add parallax Z translation if (parallaxEnabled && parallaxOffset.z > 0) { transforms.push(`translateZ(${parallaxOffset.z}px)`); } // Add wobble rotation if (wobbleEnabled && velocityRef.current !== 0) { transforms.push(Abacus3DUtils.getWobbleRotation(velocityRef.current, 'x')); } return transforms.join(' '); }, ), transformOrigin: 'center center', transformStyle: 'preserve-3d', cursor: enableGestures ? "grab" : onClick ? "pointer" : "default", touchAction: "none" as const, transition: "opacity 0.2s ease-in-out", } : { cursor: enableGestures ? "grab" : onClick ? "pointer" : "default", touchAction: "none" as const, transition: "opacity 0.2s ease-in-out", }; return ( { // Prevent click if a gesture just triggered to avoid double-toggling if (enableGestures && gestureStateRef.current.hasGestureTriggered) { e.preventDefault(); return; } onClick?.(e); }} // Enable click with gesture conflict prevention > {renderShape()} {showDirectionIndicator && direction && (() => { const indicatorTransform: any = enableAnimation ? to([arrowPulse], (pulse) => { const centerX = shape === "diamond" ? size * 0.7 : size / 2; const centerY = size / 2; return `translate(${centerX}, ${centerY}) scale(${pulse})`; }) : getDirectionIndicatorTransform(); return ( {(() => { const arrowColors = getArrowColors( bead, direction, totalColumns, colorScheme, colorPalette, ); const isUpArrow = direction === "up" || (direction === "activate" && bead.type === "earth") || (direction === "deactivate" && bead.type === "heaven"); return isUpArrow ? ( // Up arrow - centered with color scheme ) : ( // Down arrow - centered with color scheme ); })()} ); })()} ); }; // Main component export const AbacusReact: React.FC = ({ value = 0, columns = "auto", showEmptyColumns = false, hideInactiveBeads, beadShape, colorScheme, colorPalette, scaleFactor, animated, interactive, gestures, showNumbers, soundEnabled, soundVolume, // 3D enhancement props enhanced3d, material3d, physics3d, // Advanced customization props customStyles, callbacks, overlays = [], highlightColumns = [], highlightBeads = [], stepBeadHighlights = [], currentStep = 0, showDirectionIndicators = false, disabledColumns = [], disabledBeads = [], // Legacy callbacks onClick, onValueChange, }) => { // Try to use context config, fallback to defaults if no context let contextConfig; try { contextConfig = useAbacusConfig(); } catch { // No context provider, use defaults contextConfig = getDefaultAbacusConfig(); } // Use props if provided, otherwise fall back to context config const finalConfig = { hideInactiveBeads: hideInactiveBeads ?? contextConfig.hideInactiveBeads, beadShape: beadShape ?? contextConfig.beadShape, colorScheme: colorScheme ?? contextConfig.colorScheme, colorPalette: colorPalette ?? contextConfig.colorPalette, scaleFactor: scaleFactor ?? contextConfig.scaleFactor, animated: animated ?? contextConfig.animated, interactive: interactive ?? contextConfig.interactive, gestures: gestures ?? contextConfig.gestures, showNumbers: showNumbers ?? contextConfig.showNumbers, soundEnabled: soundEnabled ?? contextConfig.soundEnabled, soundVolume: soundVolume ?? contextConfig.soundVolume, }; // Calculate effective columns first, without depending on columnStates const effectiveColumns = useMemo(() => { if (columns === "auto") { const minColumns = Math.max(1, value.toString().length); return showEmptyColumns ? minColumns : minColumns; } return columns; }, [columns, value, showEmptyColumns]); // Switch to place-value architecture! const maxPlaceValue = (effectiveColumns - 1) as ValidPlaceValues; const { value: currentValue, placeStates, toggleBead, getPlaceState, setPlaceState, } = useAbacusPlaceStates(value, maxPlaceValue); // Legacy compatibility - convert placeStates back to columnStates for components that still need it const columnStates = useMemo(() => { const states: ColumnState[] = []; for (let col = 0; col < effectiveColumns; col++) { const placeValue = (effectiveColumns - 1 - col) as ValidPlaceValues; const placeState = placeStates.get(placeValue); states[col] = placeState ? { heavenActive: placeState.heavenActive, earthActive: placeState.earthActive, } : { heavenActive: false, earthActive: 0 }; } return states; }, [placeStates, effectiveColumns]); // Legacy setColumnState for backward compatibility during transition const setColumnState = useCallback( (columnIndex: number, state: ColumnState) => { const placeValue = (effectiveColumns - 1 - columnIndex) as ValidPlaceValues; if (placeStates.has(placeValue)) { const currentState = placeStates.get(placeValue)!; // This would need the place state setter from the hook - simplified for now console.warn( "setColumnState called - should migrate to place value operations", ); } }, [placeStates, effectiveColumns], ); // Track when changes are from external control vs user interaction const isExternalChange = useRef(false); const previousControlledValue = useRef(value); // Debug prop changes and mark external changes React.useEffect(() => { // console.log(`🔄 Component received value prop: ${value}, internal value: ${currentValue}`); // Mark this as an external change when controlled value prop changes if (value !== previousControlledValue.current) { isExternalChange.current = true; } }, [value, currentValue]); // Notify about value changes only when user interacts (not external control) React.useEffect(() => { // Skip callback if this change was from external control if (isExternalChange.current) { isExternalChange.current = false; return; } // Skip callback if value hasn't actually changed from user interaction if (currentValue === previousControlledValue.current) { return; } // This is a user-initiated change, notify parent onValueChange?.(currentValue); }, [currentValue, onValueChange]); // Track controlled value changes React.useEffect(() => { previousControlledValue.current = value; }, [value]); const dimensions = useAbacusDimensions( effectiveColumns, finalConfig.scaleFactor, finalConfig.showNumbers, ); // Use new place-value bead calculation! const beadStates = useMemo( () => calculateBeadStatesFromPlaces(placeStates, maxPlaceValue), [placeStates, maxPlaceValue], ); // Layout calculations using exact Typst positioning // In Typst, the reckoning bar is positioned at heaven-earth-gap from the top const barY = dimensions.heavenEarthGap; const handleBeadClick = useCallback( (bead: BeadConfig, event?: React.MouseEvent) => { // Check if bead is disabled using new place-value system const columnIndex = effectiveColumns - 1 - bead.placeValue; // Convert place value to legacy column index for disabled check const isDisabled = isBeadDisabledByPlaceValue(bead, disabledBeads) || disabledColumns?.includes(columnIndex); if (isDisabled) { return; } // Calculate how many beads will change to determine sound intensity const currentState = getPlaceState(bead.placeValue); let beadMovementCount = 1; // Default for single bead movements if (bead.type === "earth") { if (bead.active) { // Deactivating: count beads from this position to end of active beads beadMovementCount = currentState.earthActive - bead.position; } else { // Activating: count beads from current active count to this position + 1 beadMovementCount = bead.position + 1 - currentState.earthActive; } } // Heaven bead always moves just 1 bead // Create enhanced event object const beadClickEvent: BeadClickEvent = { bead, columnIndex: columnIndex, // Legacy API compatibility beadType: bead.type, position: bead.position, active: bead.active, value: bead.value, event: event!, }; // Call new callback system callbacks?.onBeadClick?.(beadClickEvent); // Legacy callback for backward compatibility onClick?.(bead); // Play sound if enabled with intensity based on bead movement count if (finalConfig.soundEnabled) { playBeadSound(finalConfig.soundVolume, beadMovementCount); } // Toggle the bead - NO MORE EFFECTIVECOLUMNS THREADING! toggleBead(bead); }, [ onClick, callbacks, toggleBead, disabledColumns, disabledBeads, finalConfig.soundEnabled, finalConfig.soundVolume, getPlaceState, ], ); const handleGestureToggle = useCallback( (bead: BeadConfig, direction: "activate" | "deactivate") => { const currentState = getPlaceState(bead.placeValue); // Calculate bead movement count for sound intensity let beadMovementCount = 1; if (bead.type === "earth") { if (direction === "activate") { beadMovementCount = Math.max( 0, bead.position + 1 - currentState.earthActive, ); } else { beadMovementCount = Math.max( 0, currentState.earthActive - bead.position, ); } } // Play sound if enabled with intensity if (finalConfig.soundEnabled) { playBeadSound(finalConfig.soundVolume, beadMovementCount); } if (bead.type === "heaven") { // Heaven bead: directly set the state based on direction const newHeavenActive = direction === "activate"; setPlaceState(bead.placeValue, { ...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); } setPlaceState(bead.placeValue, { ...currentState, earthActive: newEarthActive, }); } }, [ getPlaceState, setPlaceState, finalConfig.soundEnabled, finalConfig.soundVolume, ], ); // Place value editing - FRESH IMPLEMENTATION const [activeColumn, setActiveColumn] = React.useState(null); // Calculate current place values const placeValues = React.useMemo(() => { return columnStates.map( (state) => (state.heavenActive ? 5 : 0) + state.earthActive, ); }, [columnStates]); // Update a column from a digit const setColumnValue = React.useCallback( (columnIndex: number, digit: number) => { if (digit < 0 || digit > 9) return; // Convert column index to place value const placeValue = (effectiveColumns - 1 - columnIndex) as ValidPlaceValues; const currentState = getPlaceState(placeValue); // Calculate how many beads change for sound intensity const currentValue = (currentState.heavenActive ? 5 : 0) + currentState.earthActive; const newHeavenActive = digit >= 5; const newEarthActive = digit % 5; // Count bead movements: heaven bead + earth bead changes let beadMovementCount = 0; if (currentState.heavenActive !== newHeavenActive) beadMovementCount += 1; beadMovementCount += Math.abs(currentState.earthActive - newEarthActive); // Play sound if enabled with intensity based on bead changes if (finalConfig.soundEnabled && beadMovementCount > 0) { playBeadSound(finalConfig.soundVolume, beadMovementCount); } setPlaceState(placeValue, { heavenActive: newHeavenActive, earthActive: newEarthActive, }); }, [ setPlaceState, effectiveColumns, finalConfig.soundEnabled, finalConfig.soundVolume, getPlaceState, ], ); // Keyboard handler - only active when interactive React.useEffect(() => { // Clear activeColumn if abacus becomes non-interactive if (!finalConfig.interactive && activeColumn !== null) { setActiveColumn(null); return; } // Only set up keyboard listener when interactive if (!finalConfig.interactive) { return; } 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.preventDefault(); // console.log(`⬅️ BACKSPACE: clearing current column and moving to previous column`); // Clear current column (set to 0) setColumnValue(activeColumn, 0); // 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.shiftKey) { e.preventDefault(); // console.log(`⬅️ SHIFT+TAB: moving to higher place value (left)`); // Shift+Tab moves LEFT (to higher place values): ones → tens → hundreds // Lower columnIndex = higher place value const nextColumn = activeColumn - 1; if (nextColumn >= 0) { // console.log(`⬅️ Moving focus to higher place value: ${nextColumn}`); setActiveColumn(nextColumn); } else { // console.log(`🏁 Reached highest place, wrapping to ones place`); setActiveColumn(effectiveColumns - 1); // Wrap to rightmost (ones place) } } else if (e.key === "Tab") { e.preventDefault(); // console.log(`➡️ TAB: moving to lower place value (right)`); // Tab moves RIGHT (to lower place values): hundreds → tens → ones // Higher columnIndex = lower place value const nextColumn = activeColumn + 1; if (nextColumn < effectiveColumns) { // console.log(`➡️ Moving focus to lower place value: ${nextColumn}`); setActiveColumn(nextColumn); } else { // console.log(`🏁 Reached lowest place, wrapping to highest place`); setActiveColumn(0); // Wrap to leftmost (highest place) } } 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, finalConfig.interactive]); // Debug activeColumn changes React.useEffect(() => { // console.log(`🎯 activeColumn changed to: ${activeColumn}`); }, [activeColumn]); // 3D Enhancement: Calculate container classes const containerClasses = Abacus3DUtils.get3DContainerClasses( enhanced3d, material3d?.lighting, physics3d?.hoverParallax ); // 3D Enhancement: Track mouse position for parallax const containerRef = useRef(null); const [mousePos, setMousePos] = React.useState({ x: 0, y: 0 }); const [containerBounds, setContainerBounds] = React.useState({ x: 0, y: 0 }); const handleMouseMove = useCallback((e: React.MouseEvent) => { if (enhanced3d === 'delightful' && physics3d?.hoverParallax && containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); setMousePos({ x: e.clientX, y: e.clientY }); setContainerBounds({ x: rect.left, y: rect.top }); } }, [enhanced3d, physics3d?.hoverParallax]); return (
{ if ( finalConfig.interactive && finalConfig.showNumbers && activeColumn === null ) { // Start at the rightmost column (ones place) setActiveColumn(effectiveColumns - 1); } }} onBlur={() => { if (finalConfig.interactive && finalConfig.showNumbers) { setActiveColumn(null); } }} > {/* 3D Enhancement: Material gradients for beads */} {(enhanced3d === 'realistic' || enhanced3d === 'delightful') && material3d && ( <> {/* Generate gradients for all beads based on material type */} {Array.from({ length: effectiveColumns }, (_, colIndex) => { const placeValue = (effectiveColumns - 1 - colIndex) as ValidPlaceValues; // Create dummy beads to get their colors const heavenBead: BeadConfig = { type: 'heaven', value: 5, active: true, position: 0, placeValue }; const earthBead: BeadConfig = { type: 'earth', value: 1, active: true, position: 0, placeValue }; const heavenColor = getBeadColor(heavenBead, effectiveColumns, finalConfig.colorScheme, finalConfig.colorPalette, false); const earthColor = getBeadColor(earthBead, effectiveColumns, finalConfig.colorScheme, finalConfig.colorPalette, false); return ( {/* Heaven bead gradient */} {/* Earth bead gradients */} {[0, 1, 2, 3].map(pos => ( ))} ); }).filter(Boolean)} {/* Wood grain texture pattern */} {material3d.woodGrain && ( )} )} {/* Background glow effects - rendered behind everything */} {Array.from({ length: effectiveColumns }, (_, colIndex) => { const placeValue = effectiveColumns - 1 - colIndex; const columnStyles = customStyles?.columns?.[colIndex]; const backgroundGlow = columnStyles?.backgroundGlow; if (!backgroundGlow) return null; const x = colIndex * dimensions.rodSpacing + dimensions.rodSpacing / 2; const glowWidth = dimensions.rodSpacing + (backgroundGlow.spread || 0); const glowHeight = dimensions.height + (backgroundGlow.spread || 0); return ( ); })} {/* Rods - positioned as rectangles like in Typst */} {Array.from({ length: effectiveColumns }, (_, colIndex) => { const placeValue = effectiveColumns - 1 - 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 // Apply custom column post styling (column-specific overrides global) const columnStyles = customStyles?.columns?.[colIndex]; const globalColumnPosts = customStyles?.columnPosts; const rodStyle = { fill: columnStyles?.columnPost?.fill || globalColumnPosts?.fill || "rgb(0, 0, 0, 0.1)", // Default Typst color stroke: columnStyles?.columnPost?.stroke || globalColumnPosts?.stroke || "none", strokeWidth: columnStyles?.columnPost?.strokeWidth ?? globalColumnPosts?.strokeWidth ?? 0, opacity: columnStyles?.columnPost?.opacity ?? globalColumnPosts?.opacity ?? 1, }; return ( {/* Wood grain texture overlay for column posts */} {(enhanced3d === 'realistic' || enhanced3d === 'delightful') && material3d?.woodGrain && ( )} ); })} {/* Reckoning bar - spans from leftmost to rightmost bead */} {/* Wood grain texture overlay for reckoning bar */} {(enhanced3d === 'realistic' || enhanced3d === 'delightful') && material3d?.woodGrain && ( )} {/* 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 = columnStates[colIndex]; if (!columnState) { throw new Error( `Invalid abacus state: columnState is undefined for column index ${colIndex}. ` + `effectiveColumns=${effectiveColumns}, columnStates.length=${columnStates.length}, ` + `beadStates.length=${beadStates.length}, placeValue=${bead.placeValue}. ` + `This indicates a mismatch between the number of columns and the bead states. ` + `Please report this issue with the abacus configuration that triggered it.` ); } 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); } } } // Check if bead is highlighted - NO MORE EFFECTIVECOLUMNS THREADING! const regularHighlight = isBeadHighlightedByPlaceValue( bead, highlightBeads, ); const stepHighlight = getBeadStepHighlight( bead, stepBeadHighlights, currentStep, ); const isHighlighted = regularHighlight || stepHighlight.isHighlighted; const color = getBeadColor( bead, effectiveColumns, finalConfig.colorScheme, finalConfig.colorPalette, isHighlighted, ); // Apply custom styling const beadStyle = mergeBeadStyles( { fill: color }, customStyles, effectiveColumns - 1 - bead.placeValue, // Convert place value to column index for styling bead.type, bead.type === "earth" ? bead.position : undefined, bead.active, ); // Check if bead is disabled - NO MORE EFFECTIVECOLUMNS THREADING! const isDisabled = isBeadDisabledByPlaceValue(bead, disabledBeads) || disabledColumns?.includes(effectiveColumns - 1 - bead.placeValue); return ( handleBeadClick(bead, event) : undefined } onHover={ callbacks?.onBeadHover ? (event) => { const beadClickEvent: BeadClickEvent = { bead, columnIndex: effectiveColumns - 1 - bead.placeValue, // Convert place value to column index for callback beadType: bead.type, position: bead.position, active: bead.active, value: bead.value, event, }; callbacks.onBeadHover?.(beadClickEvent); } : undefined } onLeave={ callbacks?.onBeadLeave ? (event) => { const beadClickEvent: BeadClickEvent = { bead, columnIndex: effectiveColumns - 1 - bead.placeValue, // Convert place value to column index for callback beadType: bead.type, position: bead.position, active: bead.active, value: bead.value, event, }; callbacks.onBeadLeave?.(beadClickEvent); } : undefined } onGestureToggle={handleGestureToggle} onRef={ callbacks?.onBeadRef ? (element) => callbacks.onBeadRef!(bead, element) : undefined } heavenEarthGap={dimensions.heavenEarthGap} barY={barY} colorScheme={finalConfig.colorScheme} colorPalette={finalConfig.colorPalette} totalColumns={effectiveColumns} enhanced3d={enhanced3d} material3d={material3d} physics3d={physics3d} columnIndex={colIndex} mousePosition={mousePos} containerBounds={containerBounds} /> ); }), )} {/* Background rectangles for place values - in SVG */} {finalConfig.showNumbers && placeValues.map((value, columnIndex) => { const placeValue = effectiveColumns - 1 - 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 * finalConfig.scaleFactor) + 10 * finalConfig.scaleFactor; const y = baseHeight + 25; const isActive = activeColumn === columnIndex; return ( setActiveColumn(columnIndex) : undefined } /> ); })} {/* NumberFlow place value displays - inside SVG using foreignObject */} {finalConfig.showNumbers && placeValues.map((value, columnIndex) => { const placeValue = effectiveColumns - 1 - 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 * finalConfig.scaleFactor) + 10 * finalConfig.scaleFactor; const y = baseHeight + 25; return (
setActiveColumn(columnIndex) : undefined } >
); })} {/* Overlay system for tooltips, arrows, highlights, etc. */} {overlays.map((overlay) => { if (overlay.visible === false) return null; let position = { x: 0, y: 0 }; // Calculate overlay position based on target if (overlay.target.type === "bead") { // Find the bead position const targetColumn = overlay.target.columnIndex; const targetBeadType = overlay.target.beadType; const targetBeadPosition = overlay.target.beadPosition; if (targetColumn !== undefined && targetBeadType) { const x = targetColumn * dimensions.rodSpacing + dimensions.rodSpacing / 2; let y = 0; if (targetBeadType === "heaven") { const columnState = columnStates[targetColumn]; if (!columnState) { console.error( `Invalid abacus overlay: columnState is undefined for overlay targeting column ${targetColumn}` ); return; } y = columnState.heavenActive ? dimensions.heavenEarthGap - dimensions.beadSize / 2 - dimensions.activeGap : dimensions.heavenEarthGap - dimensions.inactiveGap - dimensions.beadSize / 2; } else if ( targetBeadType === "earth" && targetBeadPosition !== undefined ) { const columnState = columnStates[targetColumn]; if (!columnState) { console.error( `Invalid abacus overlay: columnState is undefined for overlay targeting column ${targetColumn}` ); return; } const earthActive = columnState.earthActive; const isActive = targetBeadPosition < earthActive; if (isActive) { y = dimensions.heavenEarthGap + dimensions.barThickness + dimensions.activeGap + dimensions.beadSize / 2 + targetBeadPosition * (dimensions.beadSize + dimensions.adjacentSpacing); } else { if (earthActive > 0) { y = dimensions.heavenEarthGap + dimensions.barThickness + dimensions.activeGap + dimensions.beadSize / 2 + (earthActive - 1) * (dimensions.beadSize + dimensions.adjacentSpacing) + dimensions.beadSize / 2 + dimensions.inactiveGap + dimensions.beadSize / 2 + (targetBeadPosition - earthActive) * (dimensions.beadSize + dimensions.adjacentSpacing); } else { y = dimensions.heavenEarthGap + dimensions.barThickness + dimensions.inactiveGap + dimensions.beadSize / 2 + targetBeadPosition * (dimensions.beadSize + dimensions.adjacentSpacing); } } } position = calculateOverlayPosition( overlay, dimensions, targetColumn, { x, y }, ); } } else { position = calculateOverlayPosition(overlay, dimensions); } return (
{overlay.content}
); })} {/* Column interaction areas - rendered last to be on top of all other elements */} {Array.from({ length: effectiveColumns }, (_, colIndex) => { const placeValue = effectiveColumns - 1 - colIndex; const x = colIndex * dimensions.rodSpacing + dimensions.rodSpacing / 2; const columnStyles = customStyles?.columns?.[colIndex]; const hasColumnHighlight = columnStyles?.columnPost; const backgroundWidth = dimensions.rodSpacing; // Full column width for better interaction const backgroundHeight = dimensions.height; return ( callbacks?.onColumnClick?.(colIndex, e)} onMouseEnter={(e) => callbacks?.onColumnHover?.(colIndex, e)} onMouseLeave={(e) => callbacks?.onColumnLeave?.(colIndex, e)} /> ); })}
); }; export default AbacusReact;