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:
File diff suppressed because it is too large
Load Diff
369
packages/abacus-react/src/AbacusSVGRenderer.tsx
Normal file
369
packages/abacus-react/src/AbacusSVGRenderer.tsx
Normal 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
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user