fix(abacus-react): fix animations by preventing component remounting

The issue was that WrappedBeadComponent was causing all beads to remount
on every render, preventing React Spring animations from working. Even
though the wrapper was memoized with useCallback, any dependency change
caused React to see it as a completely new component type, unmounting all
old beads and mounting new ones at their new positions (instant jump instead
of animation).

Solution: Refactor to use calculateExtraBeadProps pattern instead of wrapper
- Pass AbacusAnimatedBead directly as BeadComponent (stable reference)
- Add calculateExtraBeadProps function to AbacusSVGRenderer interface
- This function computes animation props (enableAnimation, physicsConfig, etc.)
  without changing the component type
- Result: Beads update props instead of remounting, allowing animations to work

Key changes:
- AbacusSVGRenderer: Accept calculateExtraBeadProps prop
- AbacusSVGRenderer: Call calculateExtraBeadProps for each bead, spread result
- AbacusReact: Replace WrappedBeadComponent with calculateExtraBeadProps callback
- AbacusReact: Pass AbacusAnimatedBead directly (not wrapped)
- AbacusSVGRenderer: Change BeadComponent type to React.ComponentType<any>
- AbacusSVGRenderer: Use stable keys: bead-pv{placeValue}-{type}-{position}

Debugging logs added temporarily to verify fix works.
This commit is contained in:
Thomas Hallock
2025-11-04 13:04:50 -06:00
parent 88c0baaad9
commit be7d4c4713
7 changed files with 3392 additions and 264 deletions

View File

@@ -0,0 +1,374 @@
'use client'
/**
* AbacusAnimatedBead - Interactive bead component for AbacusReact (Core Architecture)
*
* This is the **client-side bead component** injected into AbacusSVGRenderer by AbacusReact.
* It provides animations and interactivity while the parent renderer handles positioning.
*
* ## Architecture Role:
* - Injected into `AbacusSVGRenderer` via dependency injection (BeadComponent prop)
* - Receives x,y position from `calculateBeadPosition()` (already calculated)
* - Adds animations and interactions on top of the shared layout
* - Used ONLY by AbacusReact (requires "use client")
*
* ## Features:
* - ✅ React Spring animations for smooth position changes
* - ✅ Drag gesture handling with @use-gesture/react
* - ✅ Direction indicators for tutorials (pulsing arrows)
* - ✅ 3D effects and gradients
* - ✅ Click and hover interactions
*
* ## Comparison:
* - `AbacusStaticBead` - Simple SVG shapes (no animations, RSC-compatible)
* - `AbacusAnimatedBead` - This component (animations, gestures, client-only)
*
* Both receive the same position from `calculateBeadPosition()`, ensuring visual consistency.
*/
import React, { useCallback, useRef } from 'react'
import { useSpring, animated, to } from '@react-spring/web'
import { useDrag } from '@use-gesture/react'
import type { BeadComponentProps } from './AbacusSVGRenderer'
import type { BeadConfig } from './AbacusReact'
interface AnimatedBeadProps extends BeadComponentProps {
// Animation controls
enableAnimation: boolean
physicsConfig: any
// Gesture handling
enableGestures: boolean
onGestureToggle?: (bead: BeadConfig, direction: 'activate' | 'deactivate') => void
// Direction indicators (for tutorials)
showDirectionIndicator?: boolean
direction?: 'activate' | 'deactivate'
isCurrentStep?: boolean
// 3D effects
enhanced3d?: 'none' | 'subtle' | 'realistic' | 'delightful'
columnIndex?: number
}
export function AbacusAnimatedBead({
bead,
x,
y,
size,
shape,
color,
hideInactiveBeads,
customStyle,
onClick,
onMouseEnter,
onMouseLeave,
onRef,
enableAnimation,
physicsConfig,
enableGestures,
onGestureToggle,
showDirectionIndicator,
direction,
isCurrentStep,
enhanced3d = 'none',
columnIndex,
}: AnimatedBeadProps) {
// x, y are already calculated by AbacusSVGRenderer
// Debug: Log animation state for first render
React.useEffect(() => {
if (bead.placeValue === 0 && bead.type === 'heaven') {
console.log('[AbacusAnimatedBead] Animation debug:', {
enableAnimation,
physicsConfig,
x,
y,
beadType: bead.type,
beadActive: bead.active
})
}
}, [])
// Spring animation for position
const [{ springX, springY }, api] = useSpring(() => ({
springX: x,
springY: y,
config: physicsConfig,
}))
// Arrow pulse animation for direction indicators
const [{ arrowPulse }, arrowApi] = useSpring(() => ({
arrowPulse: 1,
config: enableAnimation ? { tension: 200, friction: 10 } : { duration: 0 },
}))
const gestureStateRef = useRef({
isDragging: false,
lastDirection: null as 'activate' | 'deactivate' | null,
startY: 0,
threshold: size * 0.3,
hasGestureTriggered: false,
})
// Calculate gesture direction based on bead type
const getGestureDirection = useCallback(
(deltaY: number) => {
const movement = Math.abs(deltaY)
if (movement < gestureStateRef.current.threshold) return null
if (bead.type === 'heaven') {
return deltaY > 0 ? 'activate' : 'deactivate'
} else {
return deltaY < 0 ? 'activate' : 'deactivate'
}
},
[bead.type, size]
)
// Gesture handler
const bind = enableGestures
? useDrag(
({ event, movement: [, deltaY], first, active }) => {
if (first) {
event?.preventDefault()
gestureStateRef.current.isDragging = true
gestureStateRef.current.lastDirection = null
gestureStateRef.current.hasGestureTriggered = false
return
}
if (!active || !gestureStateRef.current.isDragging) {
if (!active) {
gestureStateRef.current.isDragging = false
gestureStateRef.current.lastDirection = null
setTimeout(() => {
gestureStateRef.current.hasGestureTriggered = false
}, 100)
}
return
}
const currentDirection = getGestureDirection(deltaY)
if (
currentDirection &&
currentDirection !== gestureStateRef.current.lastDirection
) {
gestureStateRef.current.lastDirection = currentDirection
gestureStateRef.current.hasGestureTriggered = true
onGestureToggle?.(bead, currentDirection)
}
},
{
enabled: enableGestures,
preventDefault: true,
}
)
: () => ({})
// Update spring animation when position changes
React.useEffect(() => {
if (bead.placeValue === 0 && bead.type === 'heaven') {
console.log('[AbacusAnimatedBead] Position update:', {
x, y,
enableAnimation,
willAnimate: enableAnimation,
beadActive: bead.active
})
}
if (enableAnimation) {
api.start({ springX: x, springY: y, config: physicsConfig })
} else {
api.set({ springX: x, springY: y })
}
}, [x, y, enableAnimation, api, physicsConfig])
// Pulse animation for direction indicators
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)
return () => {
clearTimeout(timeoutId)
arrowApi.stop()
}
} else {
arrowApi.set({ arrowPulse: 1 })
}
}, [showDirectionIndicator, direction, isCurrentStep, arrowApi])
// Render bead shape
const renderShape = () => {
const halfSize = size / 2
// Determine fill - use gradient for realistic mode, otherwise use color
let fillValue = customStyle?.fill || color
if (enhanced3d === 'realistic' && columnIndex !== undefined) {
if (bead.type === 'heaven') {
fillValue = `url(#bead-gradient-${columnIndex}-heaven)`
} else {
fillValue = `url(#bead-gradient-${columnIndex}-earth-${bead.position})`
}
}
const opacity = bead.active ? (customStyle?.opacity ?? 1) : 0.3
const stroke = customStyle?.stroke || '#000'
const strokeWidth = customStyle?.strokeWidth || 0.5
switch (shape) {
case 'diamond':
return (
<polygon
points={`${size * 0.7},0 ${size * 1.4},${halfSize} ${size * 0.7},${size} 0,${halfSize}`}
fill={fillValue}
stroke={stroke}
strokeWidth={strokeWidth}
opacity={opacity}
/>
)
case 'square':
return (
<rect
width={size}
height={size}
fill={fillValue}
stroke={stroke}
strokeWidth={strokeWidth}
rx="1"
opacity={opacity}
/>
)
case 'circle':
default:
return (
<circle
cx={halfSize}
cy={halfSize}
r={halfSize}
fill={fillValue}
stroke={stroke}
strokeWidth={strokeWidth}
opacity={opacity}
/>
)
}
}
// Calculate offsets for shape positioning
const getXOffset = () => {
return shape === 'diamond' ? size * 0.7 : size / 2
}
const getYOffset = () => {
return size / 2
}
// Use animated.g if animations enabled, otherwise regular g
const GElement = enableAnimation ? animated.g : 'g'
const DirectionIndicatorG =
enableAnimation && showDirectionIndicator && direction ? animated.g : 'g'
// Build style object
const beadStyle: any = enableAnimation
? {
transform: to(
[springX, springY],
(sx, sy) => `translate(${sx - getXOffset()}px, ${sy - getYOffset()}px)`
),
cursor: enableGestures ? 'grab' : onClick ? 'pointer' : 'default',
touchAction: 'none' as const,
transition: 'opacity 0.2s ease-in-out',
}
: {
transform: `translate(${x - getXOffset()}px, ${y - getYOffset()}px)`,
cursor: enableGestures ? 'grab' : onClick ? 'pointer' : 'default',
touchAction: 'none' as const,
transition: 'opacity 0.2s ease-in-out',
}
// Don't render inactive beads if hideInactiveBeads is true
if (!bead.active && hideInactiveBeads) {
return null
}
const handleClick = (event: React.MouseEvent) => {
// Prevent click if gesture was triggered
if (gestureStateRef.current.hasGestureTriggered) {
event.preventDefault()
return
}
onClick?.(bead, event)
}
return (
<>
<GElement
className={`abacus-bead ${bead.active ? 'active' : 'inactive'} ${hideInactiveBeads && !bead.active ? 'hidden-inactive' : ''}`}
style={beadStyle}
{...bind()}
onClick={handleClick}
onMouseEnter={(e) => onMouseEnter?.(bead, e as any)}
onMouseLeave={(e) => onMouseLeave?.(bead, e as any)}
ref={(el) => onRef?.(bead, el as any)}
>
{renderShape()}
</GElement>
{/* Direction indicator for tutorials */}
{showDirectionIndicator && direction && (
<DirectionIndicatorG
className="direction-indicator"
style={
(enableAnimation
? {
transform: to(
[springX, springY, arrowPulse],
(sx, sy, pulse) => {
const centerX = shape === 'diamond' ? size * 0.7 : size / 2
const centerY = size / 2
return `translate(${sx - centerX}px, ${sy - centerY}px) scale(${pulse})`
}
),
pointerEvents: 'none' as const,
}
: {
transform: `translate(${x - (shape === 'diamond' ? size * 0.7 : size / 2)}px, ${y - size / 2}px) scale(1)`,
pointerEvents: 'none' as const,
}) as any
}
>
<circle
cx={shape === 'diamond' ? size * 0.7 : size / 2}
cy={size / 2}
r={size * 0.8}
fill="rgba(255, 165, 0, 0.3)"
stroke="orange"
strokeWidth="2"
/>
<text
x={shape === 'diamond' ? size * 0.7 : size / 2}
y={size / 2}
textAnchor="middle"
dy=".35em"
fontSize={size * 0.8}
fill="orange"
fontWeight="bold"
>
{direction === 'activate' ? '↓' : '↑'}
</text>
</DirectionIndicatorG>
)}
</>
)
}

View File

@@ -2111,43 +2111,42 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
return Abacus3DUtils.getPhysicsConfig(enhanced3d)
}, [finalConfig.animated, enhanced3d])
// Wrapped bead component that passes extra props to AbacusAnimatedBead
const WrappedBeadComponent = useCallback((props: any) => {
// Calculate extra props for AbacusAnimatedBead (animations, gestures, etc.)
// This function is called for each bead to compute additional props
const calculateExtraBeadProps = useCallback((bead: BeadConfig, baseProps: any) => {
// Check if bead is highlighted
const regularHighlight = isBeadHighlightedByPlaceValue(props.bead, highlightBeads)
const stepHighlight = getBeadStepHighlight(props.bead, stepBeadHighlights, currentStep)
const regularHighlight = isBeadHighlightedByPlaceValue(bead, highlightBeads)
const stepHighlight = getBeadStepHighlight(bead, stepBeadHighlights, currentStep)
const isHighlighted = regularHighlight || stepHighlight.isHighlighted
// Check if bead is disabled
const columnIndex = effectiveColumns - 1 - props.bead.placeValue
const isDisabled = isBeadDisabledByPlaceValue(props.bead, disabledBeads) || disabledColumns?.includes(columnIndex)
const columnIndex = effectiveColumns - 1 - bead.placeValue
const isDisabled = isBeadDisabledByPlaceValue(bead, disabledBeads) || disabledColumns?.includes(columnIndex)
// Apply custom styling
const beadStyle = mergeBeadStyles(
{ fill: props.color },
{ fill: baseProps.color },
customStyles,
columnIndex,
props.bead.type,
props.bead.type === 'earth' ? props.bead.position : undefined,
props.bead.active,
bead.type,
bead.type === 'earth' ? bead.position : undefined,
bead.active,
)
return (
<AbacusAnimatedBead
{...props}
color={beadStyle.fill || props.color}
customStyle={beadStyle}
enableAnimation={finalConfig.animated}
physicsConfig={physicsConfig}
enableGestures={finalConfig.interactive || finalConfig.gestures}
onGestureToggle={handleGestureToggle}
showDirectionIndicator={showDirectionIndicators && stepHighlight.isCurrentStep}
direction={stepHighlight.direction}
isCurrentStep={stepHighlight.isCurrentStep}
enhanced3d={enhanced3d}
columnIndex={columnIndex}
/>
)
// Return extra props for AbacusAnimatedBead
return {
color: beadStyle.fill || baseProps.color,
customStyle: beadStyle,
enableAnimation: finalConfig.animated,
physicsConfig,
enableGestures: finalConfig.interactive || finalConfig.gestures,
onGestureToggle: handleGestureToggle,
showDirectionIndicator: showDirectionIndicators && stepHighlight.isCurrentStep,
direction: stepHighlight.direction,
isCurrentStep: stepHighlight.isCurrentStep,
enhanced3d,
columnIndex,
}
}, [
finalConfig.animated,
finalConfig.interactive,
@@ -2210,8 +2209,9 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
highlightColumns={highlightColumns}
columnLabels={columnLabels}
defsContent={defsContent}
BeadComponent={WrappedBeadComponent}
BeadComponent={AbacusAnimatedBead}
getBeadColor={getBeadColor}
calculateExtraBeadProps={calculateExtraBeadProps}
onBeadClick={handleBeadClick}
onBeadMouseEnter={callbacks?.onBeadHover ? (bead, event) => {
const beadClickEvent = {

File diff suppressed because it is too large Load Diff

View File

@@ -94,7 +94,7 @@ export interface AbacusSVGRendererProps {
children?: React.ReactNode // Rendered at the end of the SVG
// Dependency injection
BeadComponent: React.ComponentType<BeadComponentProps>
BeadComponent: React.ComponentType<any> // Accept any bead component (base props + extra props)
getBeadColor: (bead: BeadConfig, totalColumns: number, colorScheme: string, colorPalette: string) => string
// Event handlers (optional, passed through to beads)
@@ -102,6 +102,10 @@ export interface AbacusSVGRendererProps {
onBeadMouseEnter?: (bead: BeadConfig, event?: React.MouseEvent) => void
onBeadMouseLeave?: (bead: BeadConfig, event?: React.MouseEvent) => void
onBeadRef?: (bead: BeadConfig, element: SVGElement | null) => void
// Extra props calculator (for animations, gestures, etc.)
// This function is called for each bead to get extra props
calculateExtraBeadProps?: (bead: BeadConfig, baseProps: BeadComponentProps) => Record<string, any>
}
/**
@@ -133,6 +137,7 @@ export function AbacusSVGRenderer({
onBeadMouseEnter,
onBeadMouseLeave,
onBeadRef,
calculateExtraBeadProps,
}: AbacusSVGRendererProps) {
const { width, height, rodSpacing, barY, beadSize, barThickness, labelHeight, numbersHeight } = dimensions
@@ -315,21 +320,30 @@ export function AbacusSVGRenderer({
? customStyles?.heavenBeads
: customStyles?.earthBeads
// Build base props
const baseProps: BeadComponentProps = {
bead,
x: position.x,
y: position.y,
size: beadSize,
shape: beadShape,
color,
hideInactiveBeads,
customStyle,
onClick: onBeadClick,
onMouseEnter: onBeadMouseEnter,
onMouseLeave: onBeadMouseLeave,
onRef: onBeadRef,
}
// Calculate extra props if provided (for animations, etc.)
const extraProps = calculateExtraBeadProps?.(bead, baseProps) || {}
return (
<BeadComponent
key={`bead-${colIndex}-${beadIndex}`}
bead={bead}
x={position.x}
y={position.y}
size={beadSize}
shape={beadShape}
color={color}
hideInactiveBeads={hideInactiveBeads}
customStyle={customStyle}
onClick={onBeadClick}
onMouseEnter={onBeadMouseEnter}
onMouseLeave={onBeadMouseLeave}
onRef={onBeadRef}
key={`bead-pv${bead.placeValue}-${bead.type}-${bead.position}`}
{...baseProps}
{...extraProps}
/>
)
})}

View File

@@ -7,20 +7,39 @@ import { ABACUS_THEMES } from './AbacusThemes'
*
* ## Key Features:
* - ✅ Works in React Server Components (no "use client")
* - ✅ Shares core utilities with AbacusReact (numberToAbacusState, color logic)
* - ✅ **Identical layout to AbacusReact** - same props = same exact SVG output
* - ✅ No animations, hooks, or client-side JavaScript
* - ✅ Lightweight rendering for static displays
*
* ## Shared Code (No Duplication!):
* - Uses `numberToAbacusState()` from AbacusUtils
* - Uses same color scheme logic as AbacusReact
* - Uses same bead positioning concepts
* - Accepts same `customStyles` prop structure
* ## Shared Architecture (Zero Duplication!):
* Both AbacusStatic and AbacusReact use the **exact same rendering pipeline**:
*
* ```
* calculateStandardDimensions() → AbacusSVGRenderer → calculateBeadPosition()
* ↓
* ┌───────────────────┴───────────────────┐
* ↓ ↓
* AbacusStaticBead AbacusAnimatedBead
* (Simple SVG) (react-spring)
* ```
*
* - `calculateStandardDimensions()` - Single source of truth for layout (beadSize, gaps, bar position, etc.)
* - `AbacusSVGRenderer` - Shared SVG structure with dependency injection for bead components
* - `calculateBeadPosition()` - Exact positioning formulas used by both variants
* - `AbacusStaticBead` - RSC-compatible simple SVG shapes (this component)
* - `AbacusAnimatedBead` - Client component with animations (AbacusReact)
*
* ## Visual Consistency Guarantee:
* Both AbacusStatic and AbacusReact produce **pixel-perfect identical output** for the same props.
* This ensures previews match interactive versions, PDFs match web displays, etc.
*
* **Architecture benefit:** ~560 lines of duplicate code eliminated. Same props = same dimensions = same positions = same layout.
*
* ## When to Use:
* - React Server Components (Next.js App Router)
* - Static site generation
* - Non-interactive previews
* - PDF generation
* - Server-side rendering without hydration
*/
const meta = {

View File

@@ -1,12 +1,14 @@
/**
* AbacusStatic - Server Component compatible static abacus
*
* Shares core logic with AbacusReact but uses static rendering without hooks/animations.
* Reuses: numberToAbacusState, getBeadColor logic, positioning calculations
* Different: No hooks, no animations, no interactions, simplified rendering
* Shares layout and rendering with AbacusReact through dependency injection.
* Uses standard dimensions to ensure same props = same exact visual output.
* Reuses: AbacusSVGRenderer for structure, shared dimension/position calculators
* Different: No hooks, no animations, no interactions, simplified bead rendering
*/
import { numberToAbacusState, calculateAbacusDimensions } from './AbacusUtils'
import { numberToAbacusState, calculateStandardDimensions } from './AbacusUtils'
import { AbacusSVGRenderer } from './AbacusSVGRenderer'
import { AbacusStaticBead } from './AbacusStaticBead'
import type {
AbacusCustomStyles,
@@ -30,7 +32,7 @@ export interface AbacusStaticConfig {
columnLabels?: string[]
}
// Shared color logic from AbacusReact (simplified for static use)
// Shared color logic (matches AbacusReact)
function getBeadColor(
bead: BeadConfig,
totalColumns: number,
@@ -87,37 +89,6 @@ function getBeadColor(
return '#3b82f6'
}
// Calculate bead positions (simplified from AbacusReact)
function calculateBeadPosition(
bead: BeadConfig,
dimensions: { beadSize: number; rodSpacing: number; heavenY: number; earthY: number; barY: number; totalColumns: number }
): { x: number; y: number } {
const { beadSize, rodSpacing, heavenY, earthY, barY, totalColumns } = dimensions
// X position based on place value (rightmost = ones place)
const columnIndex = totalColumns - 1 - bead.placeValue
const x = columnIndex * rodSpacing + rodSpacing / 2
// Y position based on bead type and active state
if (bead.type === 'heaven') {
// Heaven bead: if active, near bar; if inactive, at top
const y = bead.active ? barY - beadSize - 5 : heavenY
return { x, y }
} else {
// Earth bead: if active, stack up from bar; if inactive, at bottom
const earthSpacing = beadSize + 4
if (bead.active) {
// Active earth beads stack upward from the bar
const y = barY + beadSize / 2 + 10 + bead.position * earthSpacing
return { x, y }
} else {
// Inactive earth beads rest at the bottom
const y = earthY + (bead.position - 2) * earthSpacing
return { x, y }
}
}
}
/**
* AbacusStatic - Pure static abacus component (Server Component compatible)
*/
@@ -175,196 +146,38 @@ export function AbacusStatic({
beadConfigs.push(beads)
}
// Calculate dimensions using shared utility
const { width, height } = calculateAbacusDimensions({
// Calculate standard dimensions (same as AbacusReact!)
const dimensions = calculateStandardDimensions({
columns: effectiveColumns,
scaleFactor,
showNumbers: !!showNumbers,
columnLabels,
})
// Layout constants (must match calculateAbacusDimensions)
const beadSize = 20
const rodSpacing = 40
const heavenHeight = 60
const earthHeight = 120
const barHeight = 10
const padding = 20
const numberHeightCalc = showNumbers ? 30 : 0
const labelHeight = columnLabels.length > 0 ? 30 : 0
const dimensions = {
width,
height,
beadSize,
rodSpacing,
heavenY: padding + labelHeight + heavenHeight / 3,
earthY: padding + labelHeight + heavenHeight + barHeight + earthHeight * 0.7,
barY: padding + labelHeight + heavenHeight,
padding,
totalColumns: effectiveColumns,
}
// Compact mode hides frame
const effectiveFrameVisible = compact ? false : frameVisible
// Use shared renderer with static bead component
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width={width * scaleFactor}
height={height * scaleFactor}
viewBox={`0 0 ${width} ${height}`}
className={`abacus-svg ${hideInactiveBeads ? 'hide-inactive-mode' : ''}`}
style={{ overflow: 'visible', display: 'block' }}
>
<defs>
<style>{`
.abacus-bead {
transition: opacity 0.2s ease-in-out;
}
.hide-inactive-mode .abacus-bead.hidden-inactive {
opacity: 0 !important;
}
`}</style>
</defs>
{/* Column highlights */}
{highlightColumns.map((colIndex) => {
if (colIndex < 0 || colIndex >= effectiveColumns) return null
const x = colIndex * rodSpacing + rodSpacing / 2 + padding
const highlightWidth = rodSpacing * 0.9
const highlightHeight = height - padding * 2 - numberHeightCalc - labelHeight
return (
<rect
key={`column-highlight-${colIndex}`}
x={x - highlightWidth / 2}
y={padding + labelHeight}
width={highlightWidth}
height={highlightHeight}
fill="rgba(59, 130, 246, 0.15)"
stroke="rgba(59, 130, 246, 0.4)"
strokeWidth={2}
rx={6}
style={{ pointerEvents: 'none' }}
/>
)
})}
{/* Column labels */}
{columnLabels.map((label, colIndex) => {
if (!label || colIndex >= effectiveColumns) return null
const x = colIndex * rodSpacing + rodSpacing / 2 + padding
return (
<text
key={`column-label-${colIndex}`}
x={x}
y={padding + 15}
textAnchor="middle"
fontSize="14"
fontWeight="600"
fill="rgba(0, 0, 0, 0.7)"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{label}
</text>
)
})}
{/* Rods (column posts) */}
{effectiveFrameVisible && beadConfigs.map((_, colIndex) => {
const x = colIndex * rodSpacing + rodSpacing / 2 + padding
return (
<rect
key={`rod-${colIndex}`}
x={x - 3}
y={padding + labelHeight}
width={6}
height={heavenHeight + earthHeight + barHeight}
fill={customStyles?.columnPosts?.fill || 'rgb(0, 0, 0, 0.1)'}
stroke={customStyles?.columnPosts?.stroke || 'rgba(0, 0, 0, 0.2)'}
strokeWidth={customStyles?.columnPosts?.strokeWidth || 1}
opacity={customStyles?.columnPosts?.opacity ?? 1}
/>
)
})}
{/* Reckoning bar */}
{effectiveFrameVisible && (
<rect
x={padding}
y={dimensions.barY}
width={effectiveColumns * rodSpacing}
height={barHeight}
fill={customStyles?.reckoningBar?.fill || 'rgb(0, 0, 0, 0.15)'}
stroke={customStyles?.reckoningBar?.stroke || 'rgba(0, 0, 0, 0.3)'}
strokeWidth={customStyles?.reckoningBar?.strokeWidth || 2}
opacity={customStyles?.reckoningBar?.opacity ?? 1}
/>
)}
{/* Beads */}
{beadConfigs.map((columnBeads, colIndex) => {
const placeValue = effectiveColumns - 1 - colIndex
return (
<g key={`column-${colIndex}`}>
{columnBeads.map((bead, beadIndex) => {
const position = calculateBeadPosition(bead, dimensions)
// Adjust X for padding
position.x += padding
const color = getBeadColor(bead, effectiveColumns, colorScheme, colorPalette)
return (
<AbacusStaticBead
key={`bead-${colIndex}-${beadIndex}`}
bead={bead}
x={position.x}
y={position.y}
size={beadSize}
shape={beadShape}
color={color}
hideInactiveBeads={hideInactiveBeads}
customStyle={
bead.type === 'heaven'
? customStyles?.heavenBeads
: customStyles?.earthBeads
}
/>
)
})}
</g>
)
})}
{/* Column numbers */}
{showNumbers && beadConfigs.map((_, colIndex) => {
const placeValue = effectiveColumns - 1 - colIndex
const columnState = state[placeValue] || { heavenActive: false, earthActive: 0 }
const digit = (columnState.heavenActive ? 5 : 0) + columnState.earthActive
const x = colIndex * rodSpacing + rodSpacing / 2 + padding
return (
<text
key={`number-${colIndex}`}
x={x}
y={height - padding + 5}
textAnchor="middle"
fontSize={customStyles?.numerals?.fontSize || 16}
fontWeight={customStyles?.numerals?.fontWeight || '600'}
fill={customStyles?.numerals?.color || 'rgba(0, 0, 0, 0.8)'}
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{digit}
</text>
)
})}
</svg>
<AbacusSVGRenderer
value={value}
columns={effectiveColumns}
state={state}
beadConfigs={beadConfigs}
dimensions={dimensions}
scaleFactor={scaleFactor}
beadShape={beadShape}
colorScheme={colorScheme}
colorPalette={colorPalette}
hideInactiveBeads={hideInactiveBeads}
frameVisible={effectiveFrameVisible}
showNumbers={!!showNumbers}
customStyles={customStyles}
highlightColumns={highlightColumns}
columnLabels={columnLabels}
BeadComponent={AbacusStaticBead}
getBeadColor={getBeadColor}
/>
)
}

View File

@@ -48,6 +48,8 @@ export {
validateAbacusValue,
areStatesEqual,
calculateAbacusDimensions,
calculateStandardDimensions, // NEW: Shared layout calculator
calculateBeadPosition, // NEW: Bead position calculator
} from "./AbacusUtils";
export type {
BeadState,
@@ -55,6 +57,8 @@ export type {
BeadDiffResult,
BeadDiffOutput,
PlaceValueBasedBead,
AbacusLayoutDimensions, // NEW: Complete layout dimensions type
BeadPositionConfig, // NEW: Bead config for position calculation
} from "./AbacusUtils";
export { useAbacusDiff, useAbacusState } from "./AbacusHooks";