fix(abacus-react): restore original AbacusReact measurements and positioning

This restores the exact dimension calculations and bead positioning
formulas from the original useAbacusDimensions hook and inline positioning
logic, ensuring correct visual layout and maintaining animations.

Changes:
- Fix barY calculation: use heavenEarthGap directly (30px), not +labelSpace
- Restore original Typst positioning formulas for all beads:
  * Heaven inactive: heavenEarthGap - inactiveGap - beadSize/2
  * Earth positioning now accounts for earthActive count correctly
- Pass empty columnLabels array to calculateStandardDimensions from
  AbacusReact since it renders labels separately at y=-20
- Add columnState parameter to calculateBeadPosition() for accurate
  inactive earth bead positioning
- Update AbacusSVGRenderer to pass column state when calculating positions

This fixes the issue where beads appeared at wrong positions after the
refactor due to incorrect dimension calculations.

Related: AbacusStatic continues to work correctly with labelSpace since
it renders labels within the SVG coordinate space.
This commit is contained in:
Thomas Hallock
2025-11-04 12:47:08 -06:00
parent 20ab40b2df
commit 88c0baaad9
3 changed files with 843 additions and 725 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,369 @@
/**
* AbacusSVGRenderer - Shared SVG rendering component (Core Architecture)
*
* This is the **single SVG renderer** used by both AbacusStatic and AbacusReact to guarantee
* pixel-perfect visual consistency. It implements dependency injection to support different
* bead components while maintaining identical layout.
*
* ## Architecture Role:
* ```
* AbacusStatic + AbacusReact
* ↓
* calculateStandardDimensions() ← Single source for all layout dimensions
* ↓
* AbacusSVGRenderer ← This component (shared structure)
* ↓
* calculateBeadPosition() ← Exact positioning for every bead
* ↓
* BeadComponent (injected) ← AbacusStaticBead OR AbacusAnimatedBead
* ```
*
* ## Key Features:
* - ✅ No "use client" directive - works in React Server Components
* - ✅ No hooks or state - pure rendering from props
* - ✅ Dependency injection for bead components
* - ✅ Supports 3D gradients, background glows, overlays (via props)
* - ✅ Same props → same dimensions → same positions → same layout
*
* ## Why This Matters:
* Before this architecture, AbacusStatic and AbacusReact had ~700 lines of duplicate
* SVG rendering code with separate dimension calculations. This led to layout inconsistencies.
* Now they share this single renderer, eliminating duplication and guaranteeing consistency.
*/
import React from 'react'
import type { AbacusLayoutDimensions } from './AbacusUtils'
import type { BeadConfig, AbacusCustomStyles, ValidPlaceValues } from './AbacusReact'
import { numberToAbacusState, calculateBeadPosition, type AbacusState } from './AbacusUtils'
/**
* Props that bead components must accept
*/
export interface BeadComponentProps {
bead: BeadConfig
x: number
y: number
size: number
shape: 'circle' | 'diamond' | 'square'
color: string
hideInactiveBeads: boolean
customStyle?: {
fill?: string
stroke?: string
strokeWidth?: number
opacity?: number
}
onClick?: (bead: BeadConfig, event?: React.MouseEvent) => void
onMouseEnter?: (bead: BeadConfig, event?: React.MouseEvent) => void
onMouseLeave?: (bead: BeadConfig, event?: React.MouseEvent) => void
onRef?: (bead: BeadConfig, element: SVGElement | null) => void
}
/**
* Props for the SVG renderer
*/
export interface AbacusSVGRendererProps {
// Core data
value: number | bigint
columns: number
state: AbacusState
beadConfigs: BeadConfig[][] // Array of columns, each containing beads
// Layout
dimensions: AbacusLayoutDimensions
scaleFactor?: number
// Appearance
beadShape: 'circle' | 'diamond' | 'square'
colorScheme: string
colorPalette: string
hideInactiveBeads: boolean
frameVisible: boolean
showNumbers: boolean
customStyles?: AbacusCustomStyles
interactive?: boolean // Enable interactive CSS styles
// Tutorial features
highlightColumns?: number[]
columnLabels?: string[]
// 3D Enhancement (optional - only used by AbacusReact)
defsContent?: React.ReactNode // Custom defs content (gradients, patterns, etc.)
// Additional content (overlays, etc.)
children?: React.ReactNode // Rendered at the end of the SVG
// Dependency injection
BeadComponent: React.ComponentType<BeadComponentProps>
getBeadColor: (bead: BeadConfig, totalColumns: number, colorScheme: string, colorPalette: string) => string
// Event handlers (optional, passed through to beads)
onBeadClick?: (bead: BeadConfig, event?: React.MouseEvent) => void
onBeadMouseEnter?: (bead: BeadConfig, event?: React.MouseEvent) => void
onBeadMouseLeave?: (bead: BeadConfig, event?: React.MouseEvent) => void
onBeadRef?: (bead: BeadConfig, element: SVGElement | null) => void
}
/**
* Pure SVG renderer for abacus
* Uses dependency injection to support both static and animated beads
*/
export function AbacusSVGRenderer({
value,
columns,
state,
beadConfigs,
dimensions,
scaleFactor = 1,
beadShape,
colorScheme,
colorPalette,
hideInactiveBeads,
frameVisible,
showNumbers,
customStyles,
interactive = false,
highlightColumns = [],
columnLabels = [],
defsContent,
children,
BeadComponent,
getBeadColor,
onBeadClick,
onBeadMouseEnter,
onBeadMouseLeave,
onBeadRef,
}: AbacusSVGRendererProps) {
const { width, height, rodSpacing, barY, beadSize, barThickness, labelHeight, numbersHeight } = dimensions
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' : ''} ${interactive ? 'interactive' : ''}`}
style={{ overflow: 'visible', display: 'block' }}
>
<defs>
<style>{`
/* CSS-based opacity system for hidden inactive beads */
.abacus-bead {
transition: opacity 0.2s ease-in-out;
}
/* Hidden inactive beads are invisible by default */
.hide-inactive-mode .abacus-bead.hidden-inactive {
opacity: 0;
}
/* Interactive abacus: When hovering over the abacus, hidden inactive beads become semi-transparent */
.abacus-svg.hide-inactive-mode.interactive:hover .abacus-bead.hidden-inactive {
opacity: 0.5;
}
/* Interactive abacus: When hovering over a specific hidden inactive bead, it becomes fully visible */
.hide-inactive-mode.interactive .abacus-bead.hidden-inactive:hover {
opacity: 1 !important;
}
/* Non-interactive abacus: Hidden inactive beads always stay at opacity 0 */
.abacus-svg.hide-inactive-mode:not(.interactive) .abacus-bead.hidden-inactive {
opacity: 0 !important;
}
`}</style>
{/* Custom defs content (for 3D gradients, patterns, etc.) */}
{defsContent}
</defs>
{/* Background glow effects - rendered behind everything */}
{Array.from({ length: columns }, (_, colIndex) => {
const placeValue = columns - 1 - colIndex
const columnStyles = customStyles?.columns?.[colIndex]
const backgroundGlow = columnStyles?.backgroundGlow
if (!backgroundGlow) return null
const x = colIndex * rodSpacing + rodSpacing / 2
const glowWidth = rodSpacing + (backgroundGlow.spread || 0)
const glowHeight = height + (backgroundGlow.spread || 0)
return (
<rect
key={`background-glow-pv${placeValue}`}
x={x - glowWidth / 2}
y={-(backgroundGlow.spread || 0) / 2}
width={glowWidth}
height={glowHeight}
fill={backgroundGlow.fill || 'rgba(59, 130, 246, 0.2)'}
filter={backgroundGlow.blur ? `blur(${backgroundGlow.blur}px)` : 'none'}
opacity={backgroundGlow.opacity ?? 0.6}
rx={8}
style={{ pointerEvents: 'none' }}
/>
)
})}
{/* Column highlights */}
{highlightColumns.map((colIndex) => {
if (colIndex < 0 || colIndex >= columns) return null
const x = colIndex * rodSpacing + rodSpacing / 2
const highlightWidth = rodSpacing * 0.9
const highlightHeight = height - labelHeight - numbersHeight
return (
<rect
key={`column-highlight-${colIndex}`}
x={x - highlightWidth / 2}
y={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 >= columns) return null
const x = colIndex * rodSpacing + rodSpacing / 2
return (
<text
key={`column-label-${colIndex}`}
x={x}
y={labelHeight / 2 + 5}
textAnchor="middle"
fontSize="14"
fontWeight="600"
fill="rgba(0, 0, 0, 0.7)"
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{label}
</text>
)
})}
{/* Rods (column posts) */}
{frameVisible && beadConfigs.map((_, colIndex) => {
const placeValue = columns - 1 - colIndex
const x = colIndex * rodSpacing + rodSpacing / 2
// 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)',
stroke: columnStyles?.columnPost?.stroke || globalColumnPosts?.stroke || 'none',
strokeWidth: columnStyles?.columnPost?.strokeWidth ?? globalColumnPosts?.strokeWidth ?? 0,
opacity: columnStyles?.columnPost?.opacity ?? globalColumnPosts?.opacity ?? 1,
}
return (
<rect
key={`rod-pv${placeValue}`}
x={x - dimensions.rodWidth / 2}
y={labelHeight}
width={dimensions.rodWidth}
height={height - labelHeight - numbersHeight}
fill={rodStyle.fill}
stroke={rodStyle.stroke}
strokeWidth={rodStyle.strokeWidth}
opacity={rodStyle.opacity}
className="column-post"
/>
)
})}
{/* Reckoning bar */}
{frameVisible && (
<rect
x={0}
y={barY}
width={columns * rodSpacing}
height={barThickness}
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 - delegated to injected component */}
{beadConfigs.map((columnBeads, colIndex) => {
const placeValue = columns - 1 - colIndex
// Get column state for inactive earth bead positioning
const columnState = state[placeValue] || { heavenActive: false, earthActive: 0 }
return (
<g key={`column-${colIndex}`}>
{columnBeads.map((bead, beadIndex) => {
// Calculate position using shared utility with column state for accurate positioning
const position = calculateBeadPosition(bead, dimensions, { earthActive: columnState.earthActive })
const color = getBeadColor(bead, columns, colorScheme, colorPalette)
// Get custom style for this specific bead
const customStyle =
bead.type === 'heaven'
? customStyles?.heavenBeads
: customStyles?.earthBeads
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}
/>
)
})}
</g>
)
})}
{/* Column numbers */}
{showNumbers && beadConfigs.map((_, colIndex) => {
const placeValue = columns - 1 - colIndex
const columnState = state[placeValue] || { heavenActive: false, earthActive: 0 }
const digit = (columnState.heavenActive ? 5 : 0) + columnState.earthActive
const x = colIndex * rodSpacing + rodSpacing / 2
return (
<text
key={`number-${colIndex}`}
x={x}
y={height - numbersHeight / 2 + 5}
textAnchor="middle"
fontSize={customStyles?.numerals?.fontSize || '16px'}
fontWeight={customStyles?.numerals?.fontWeight || '600'}
fill={customStyles?.numerals?.color || 'rgba(0, 0, 0, 0.8)'}
style={{ pointerEvents: 'none', userSelect: 'none' }}
>
{digit}
</text>
)
})}
{/* Additional content (overlays, numbers, etc.) */}
{children}
</svg>
)
}
export default AbacusSVGRenderer

View File

@@ -358,13 +358,114 @@ function getPlaceName(place: number): string {
}
/**
* Calculate the natural dimensions of an abacus SVG
* This uses the same logic as AbacusStatic to ensure consistency
* Complete layout dimensions for abacus rendering
* Used by both static and dynamic rendering to ensure identical layouts
*/
export interface AbacusLayoutDimensions {
// SVG canvas size
width: number
height: number
// Bead and spacing
beadSize: number
rodSpacing: number // Same as columnSpacing
rodWidth: number
barThickness: number
// Gaps and positioning
heavenEarthGap: number // Gap between heaven and earth sections (where bar sits)
activeGap: number // Gap between active beads and reckoning bar
inactiveGap: number // Gap between inactive beads and active beads/bar
adjacentSpacing: number // Minimal spacing for adjacent beads of same type
// Key Y positions (absolute coordinates)
barY: number // Y position of reckoning bar
heavenY: number // Y position where inactive heaven beads rest
earthY: number // Y position where inactive earth beads rest
// Padding and extras
padding: number
labelHeight: number
numbersHeight: number
// Derived values
totalColumns: number
}
/**
* Calculate standard layout dimensions for abacus rendering
* This ensures both static and dynamic rendering use identical geometry
* Same props = same exact visual output
*
* @param columns - Number of columns in the abacus
* @param scaleFactor - Size multiplier (default: 1)
* @param showNumbers - Whether numbers are shown below columns
* @param columnLabels - Array of column labels (if any)
* @returns Object with width and height in pixels (at scale=1)
* @returns Complete layout dimensions object
*/
export function calculateStandardDimensions({
columns,
scaleFactor = 1,
showNumbers = false,
columnLabels = [],
}: {
columns: number
scaleFactor?: number
showNumbers?: boolean
columnLabels?: string[]
}): AbacusLayoutDimensions {
// Standard dimensions - used by both AbacusStatic and AbacusReact
const rodWidth = 3 * scaleFactor
const beadSize = 12 * scaleFactor
const adjacentSpacing = 0.5 * scaleFactor
const columnSpacing = 25 * scaleFactor
const heavenEarthGap = 30 * scaleFactor
const barThickness = 2 * scaleFactor
// Positioning gaps
const activeGap = 1 * scaleFactor
const inactiveGap = 8 * scaleFactor
// Calculate total dimensions
const totalWidth = columns * columnSpacing
const baseHeight = heavenEarthGap + 5 * (beadSize + 4 * scaleFactor) + 10 * scaleFactor
// Extra spacing
const numbersSpace = showNumbers ? 40 * scaleFactor : 0
const labelSpace = columnLabels.length > 0 ? 30 * scaleFactor : 0
const padding = 0 // No padding - keeps layout clean
const totalHeight = baseHeight + numbersSpace + labelSpace
// Key Y positions - bar is at heavenEarthGap from top
const barY = heavenEarthGap + labelSpace
const heavenY = labelSpace + activeGap // Top area for inactive heaven beads
const earthY = barY + barThickness + (4 * beadSize) + activeGap + inactiveGap // Bottom area for inactive earth
return {
width: totalWidth,
height: totalHeight,
beadSize,
rodSpacing: columnSpacing,
rodWidth,
barThickness,
heavenEarthGap,
activeGap,
inactiveGap,
adjacentSpacing,
barY,
heavenY,
earthY,
padding,
labelHeight: labelSpace,
numbersHeight: numbersSpace,
totalColumns: columns,
}
}
/**
* @deprecated Use calculateStandardDimensions instead for full layout info
* This function only returns width/height for backward compatibility
*/
export function calculateAbacusDimensions({
columns,
@@ -375,18 +476,87 @@ export function calculateAbacusDimensions({
showNumbers?: boolean
columnLabels?: string[]
}): { width: number; height: number } {
// Constants matching AbacusStatic
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 width = columns * rodSpacing + padding * 2
const height = heavenHeight + earthHeight + barHeight + padding * 2 + numberHeightCalc + labelHeight
return { width, height }
// Redirect to new function for backward compatibility
const dims = calculateStandardDimensions({ columns, scaleFactor: 1, showNumbers, columnLabels })
return { width: dims.width, height: dims.height }
}
/**
* Simplified bead config for position calculation
* (Compatible with BeadConfig from AbacusReact)
*/
export interface BeadPositionConfig {
type: 'heaven' | 'earth'
active: boolean
position: number // 0 for heaven, 0-3 for earth
placeValue: number
}
/**
* Column state needed for earth bead positioning
* (Required to calculate inactive earth bead positions correctly)
*/
export interface ColumnStateForPositioning {
earthActive: number // Number of active earth beads (0-4)
}
/**
* Calculate the x,y position for a bead based on standard layout dimensions
* This ensures both static and dynamic rendering position beads identically
* Uses exact Typst formulas from the original implementation
*
* @param bead - Bead configuration
* @param dimensions - Layout dimensions from calculateStandardDimensions
* @param columnState - Optional column state (required for inactive earth beads)
* @returns Object with x and y coordinates
*/
export function calculateBeadPosition(
bead: BeadPositionConfig,
dimensions: AbacusLayoutDimensions,
columnState?: ColumnStateForPositioning
): { x: number; y: number } {
const { beadSize, rodSpacing, heavenEarthGap, barThickness, activeGap, inactiveGap, adjacentSpacing, 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
// These formulas match the original Typst implementation exactly
if (bead.type === 'heaven') {
if (bead.active) {
// Active heaven bead: positioned close to reckoning bar (Typst line 175)
const y = heavenEarthGap - beadSize / 2 - activeGap
return { x, y }
} else {
// Inactive heaven bead: positioned away from reckoning bar (Typst line 178)
const y = heavenEarthGap - inactiveGap - beadSize / 2
return { x, y }
}
} else {
// Earth bead positioning (Typst lines 249-261)
const earthActive = columnState?.earthActive ?? 0
if (bead.active) {
// Active beads: positioned near reckoning bar, adjacent beads touch (Typst line 251)
const y = heavenEarthGap + barThickness + activeGap + beadSize / 2 +
bead.position * (beadSize + adjacentSpacing)
return { x, y }
} else {
// Inactive beads: positioned after active beads + gap (Typst lines 254-261)
let y: number
if (earthActive > 0) {
// Position after the last active bead + gap, then adjacent inactive beads touch (Typst line 256)
y = heavenEarthGap + barThickness + activeGap + beadSize / 2 +
(earthActive - 1) * (beadSize + adjacentSpacing) +
beadSize / 2 + inactiveGap + beadSize / 2 +
(bead.position - earthActive) * (beadSize + adjacentSpacing)
} else {
// No active beads: position after reckoning bar + gap, adjacent inactive beads touch (Typst line 259)
y = heavenEarthGap + barThickness + inactiveGap + beadSize / 2 +
bead.position * (beadSize + adjacentSpacing)
}
return { x, y }
}
}
}