2780 lines
92 KiB
TypeScript
2780 lines
92 KiB
TypeScript
"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<ValidPlaceValues, PlaceState>;
|
|
|
|
/**
|
|
* @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<ColumnState[]>(() =>
|
|
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<ValidPlaceValues, PlaceState>();
|
|
|
|
// 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<PlaceStatesMap>(() =>
|
|
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<PlaceState, "placeValue">,
|
|
) => {
|
|
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<BeadProps> = ({
|
|
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 (
|
|
<polygon
|
|
points={`${size * 0.7},0 ${size * 1.4},${halfSize} ${size * 0.7},${size} 0,${halfSize}`}
|
|
fill={fillValue}
|
|
stroke="#000"
|
|
strokeWidth="0.5"
|
|
/>
|
|
);
|
|
case "square":
|
|
return (
|
|
<rect
|
|
width={size}
|
|
height={size}
|
|
fill={fillValue}
|
|
stroke="#000"
|
|
strokeWidth="0.5"
|
|
rx="1"
|
|
/>
|
|
);
|
|
case "circle":
|
|
default:
|
|
return (
|
|
<circle
|
|
cx={halfSize}
|
|
cy={halfSize}
|
|
r={halfSize}
|
|
fill={fillValue}
|
|
stroke="#000"
|
|
strokeWidth="0.5"
|
|
/>
|
|
);
|
|
}
|
|
};
|
|
|
|
// 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 (
|
|
<GElement
|
|
ref={onRef}
|
|
{...(enableGestures ? bind() : {})}
|
|
className={`abacus-bead ${bead.active ? "active" : "inactive"} ${hideInactiveBeads && !bead.active ? "hidden-inactive" : ""}`}
|
|
role={onClick ? "button" : undefined}
|
|
tabIndex={onClick ? 0 : undefined}
|
|
data-testid={
|
|
onClick
|
|
? `bead-place-${bead.placeValue}-${bead.type}${bead.type === "earth" ? `-pos-${bead.position}` : ""}`
|
|
: undefined
|
|
}
|
|
transform={
|
|
enableAnimation
|
|
? undefined
|
|
: `translate(${x - getXOffset()}, ${y - getYOffset()})`
|
|
}
|
|
style={beadStyle}
|
|
onClick={(e) => {
|
|
// 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 (
|
|
<DirectionIndicatorG
|
|
className="direction-indicator"
|
|
style={{ pointerEvents: "none" as const }}
|
|
transform={indicatorTransform}
|
|
>
|
|
{(() => {
|
|
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
|
|
<polygon
|
|
points={`${-size * 0.15},${size * 0.05} ${size * 0.15},${size * 0.05} 0,${-size * 0.15}`}
|
|
fill={arrowColors.fill}
|
|
stroke={arrowColors.stroke}
|
|
strokeWidth="1.5"
|
|
/>
|
|
) : (
|
|
// Down arrow - centered with color scheme
|
|
<polygon
|
|
points={`${-size * 0.15},${-size * 0.1} ${size * 0.15},${-size * 0.1} 0,${size * 0.1}`}
|
|
fill={arrowColors.fill}
|
|
stroke={arrowColors.stroke}
|
|
strokeWidth="1.5"
|
|
/>
|
|
);
|
|
})()}
|
|
</DirectionIndicatorG>
|
|
);
|
|
})()}
|
|
</GElement>
|
|
);
|
|
};
|
|
|
|
// Main component
|
|
export const AbacusReact: React.FC<AbacusConfig> = ({
|
|
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<number | null>(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<HTMLDivElement>(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<HTMLDivElement>) => {
|
|
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 (
|
|
<div
|
|
ref={containerRef}
|
|
className={containerClasses}
|
|
style={{
|
|
display: "inline-block",
|
|
textAlign: "center",
|
|
position: "relative",
|
|
}}
|
|
onMouseMove={handleMouseMove}
|
|
tabIndex={
|
|
finalConfig.interactive && finalConfig.showNumbers ? 0 : undefined
|
|
}
|
|
onFocus={() => {
|
|
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);
|
|
}
|
|
}}
|
|
>
|
|
<svg
|
|
width={dimensions.width}
|
|
height={dimensions.height}
|
|
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
|
|
className={`abacus-svg ${finalConfig.hideInactiveBeads ? "hide-inactive-mode" : ""} ${finalConfig.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>
|
|
|
|
{/* 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 (
|
|
<React.Fragment key={`gradients-col-${colIndex}`}>
|
|
{/* Heaven bead gradient */}
|
|
<defs dangerouslySetInnerHTML={{
|
|
__html: Abacus3DUtils.getBeadGradient(
|
|
`bead-gradient-${colIndex}-heaven`,
|
|
heavenColor,
|
|
material3d.heavenBeads || 'satin',
|
|
true
|
|
)
|
|
}} />
|
|
|
|
{/* Earth bead gradients */}
|
|
{[0, 1, 2, 3].map(pos => (
|
|
<defs key={`earth-${pos}`} dangerouslySetInnerHTML={{
|
|
__html: Abacus3DUtils.getBeadGradient(
|
|
`bead-gradient-${colIndex}-earth-${pos}`,
|
|
earthColor,
|
|
material3d.earthBeads || 'satin',
|
|
true
|
|
)
|
|
}} />
|
|
))}
|
|
</React.Fragment>
|
|
);
|
|
}).filter(Boolean)}
|
|
|
|
{/* Wood grain texture pattern */}
|
|
{material3d.woodGrain && (
|
|
<defs dangerouslySetInnerHTML={{
|
|
__html: Abacus3DUtils.getWoodGrainPattern('wood-grain-pattern')
|
|
}} />
|
|
)}
|
|
</>
|
|
)}
|
|
</defs>
|
|
|
|
{/* 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 (
|
|
<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={0.6}
|
|
rx={8}
|
|
style={{ pointerEvents: "none" }}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
{/* 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 (
|
|
<React.Fragment key={`rod-pv${placeValue}`}>
|
|
<rect
|
|
x={x - dimensions.rodWidth / 2}
|
|
y={rodStartY}
|
|
width={dimensions.rodWidth}
|
|
height={rodEndY - rodStartY}
|
|
fill={rodStyle.fill}
|
|
stroke={rodStyle.stroke}
|
|
strokeWidth={rodStyle.strokeWidth}
|
|
opacity={rodStyle.opacity}
|
|
className="column-post"
|
|
/>
|
|
{/* Wood grain texture overlay for column posts */}
|
|
{(enhanced3d === 'realistic' || enhanced3d === 'delightful') && material3d?.woodGrain && (
|
|
<rect
|
|
x={x - dimensions.rodWidth / 2}
|
|
y={rodStartY}
|
|
width={dimensions.rodWidth}
|
|
height={rodEndY - rodStartY}
|
|
fill="url(#wood-grain-pattern)"
|
|
className="frame-wood"
|
|
style={{ pointerEvents: 'none' }}
|
|
/>
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
|
|
{/* Reckoning bar - spans from leftmost to rightmost bead */}
|
|
<rect
|
|
x={dimensions.rodSpacing / 2 - dimensions.beadSize / 2}
|
|
y={barY}
|
|
width={
|
|
(effectiveColumns - 1) * dimensions.rodSpacing + dimensions.beadSize
|
|
}
|
|
height={dimensions.barThickness}
|
|
fill={customStyles?.reckoningBar?.fill || "black"} // Typst default is black
|
|
stroke={customStyles?.reckoningBar?.stroke || "none"}
|
|
strokeWidth={customStyles?.reckoningBar?.strokeWidth ?? 0}
|
|
opacity={customStyles?.reckoningBar?.opacity ?? 1}
|
|
className="reckoning-bar"
|
|
/>
|
|
{/* Wood grain texture overlay for reckoning bar */}
|
|
{(enhanced3d === 'realistic' || enhanced3d === 'delightful') && material3d?.woodGrain && (
|
|
<rect
|
|
x={dimensions.rodSpacing / 2 - dimensions.beadSize / 2}
|
|
y={barY}
|
|
width={
|
|
(effectiveColumns - 1) * dimensions.rodSpacing + dimensions.beadSize
|
|
}
|
|
height={dimensions.barThickness}
|
|
fill="url(#wood-grain-pattern)"
|
|
className="frame-wood"
|
|
style={{ pointerEvents: 'none' }}
|
|
/>
|
|
)}
|
|
|
|
{/* 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 (
|
|
<Bead
|
|
key={`bead-pv${bead.placeValue}-${bead.type}-${bead.type === "earth" ? bead.position : 0}`}
|
|
bead={bead}
|
|
x={x}
|
|
y={y}
|
|
size={dimensions.beadSize}
|
|
shape={finalConfig.beadShape}
|
|
color={beadStyle.fill || color}
|
|
customStyle={beadStyle}
|
|
isHighlighted={isHighlighted}
|
|
isDisabled={isDisabled}
|
|
enableAnimation={finalConfig.animated}
|
|
enableGestures={finalConfig.interactive || finalConfig.gestures}
|
|
hideInactiveBeads={finalConfig.hideInactiveBeads}
|
|
showDirectionIndicator={
|
|
showDirectionIndicators && stepHighlight.isCurrentStep
|
|
}
|
|
direction={stepHighlight.direction}
|
|
isCurrentStep={stepHighlight.isCurrentStep}
|
|
onClick={
|
|
finalConfig.interactive && !isDisabled
|
|
? (event) => 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 (
|
|
<rect
|
|
key={`place-bg-pv${placeValue}`}
|
|
x={x - 12 * finalConfig.scaleFactor}
|
|
y={y - 12 * finalConfig.scaleFactor}
|
|
width={24 * finalConfig.scaleFactor}
|
|
height={24 * finalConfig.scaleFactor}
|
|
fill={isActive ? "#e3f2fd" : "#f5f5f5"}
|
|
stroke={isActive ? "#2196f3" : "#ccc"}
|
|
strokeWidth={
|
|
isActive
|
|
? 2 * finalConfig.scaleFactor
|
|
: 1 * finalConfig.scaleFactor
|
|
}
|
|
rx={3 * finalConfig.scaleFactor}
|
|
style={{
|
|
cursor: finalConfig.interactive ? "pointer" : "default",
|
|
}}
|
|
onClick={
|
|
finalConfig.interactive
|
|
? () => 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 (
|
|
<foreignObject
|
|
key={`place-number-pv${placeValue}`}
|
|
x={x - 12 * finalConfig.scaleFactor}
|
|
y={y - 8 * finalConfig.scaleFactor}
|
|
width={24 * finalConfig.scaleFactor}
|
|
height={16 * finalConfig.scaleFactor}
|
|
style={{ pointerEvents: "none" }}
|
|
>
|
|
<div
|
|
style={{
|
|
width: "100%",
|
|
height: "100%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
fontSize: `${Math.max(8, 14 * finalConfig.scaleFactor)}px`,
|
|
fontFamily: "monospace",
|
|
fontWeight: "bold",
|
|
pointerEvents: finalConfig.interactive ? "auto" : "none",
|
|
cursor: finalConfig.interactive ? "pointer" : "default",
|
|
}}
|
|
onClick={
|
|
finalConfig.interactive
|
|
? () => setActiveColumn(columnIndex)
|
|
: undefined
|
|
}
|
|
>
|
|
<NumberFlow
|
|
value={value}
|
|
format={{ style: "decimal" }}
|
|
style={{
|
|
fontFamily: "monospace",
|
|
fontWeight: "bold",
|
|
fontSize: `${Math.max(8, 14 * finalConfig.scaleFactor)}px`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</foreignObject>
|
|
);
|
|
})}
|
|
|
|
{/* 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 (
|
|
<foreignObject
|
|
key={overlay.id}
|
|
x={position.x}
|
|
y={position.y}
|
|
width="200"
|
|
height="100"
|
|
style={{
|
|
overflow: "visible",
|
|
pointerEvents: "none",
|
|
...overlay.style,
|
|
}}
|
|
className={overlay.className}
|
|
>
|
|
<div style={{ position: "relative", pointerEvents: "none" }}>
|
|
{overlay.content}
|
|
</div>
|
|
</foreignObject>
|
|
);
|
|
})}
|
|
|
|
{/* 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 (
|
|
<rect
|
|
key={`column-interaction-pv${placeValue}`}
|
|
x={x - backgroundWidth / 2}
|
|
y={0}
|
|
width={backgroundWidth}
|
|
height={backgroundHeight}
|
|
fill="transparent"
|
|
stroke="none"
|
|
style={{
|
|
cursor:
|
|
callbacks?.onColumnClick || callbacks?.onColumnHover
|
|
? "pointer"
|
|
: "default",
|
|
pointerEvents:
|
|
callbacks?.onColumnClick || callbacks?.onColumnHover
|
|
? "all"
|
|
: "none", // Only capture events when callbacks exist
|
|
}}
|
|
onClick={(e) => callbacks?.onColumnClick?.(colIndex, e)}
|
|
onMouseEnter={(e) => callbacks?.onColumnHover?.(colIndex, e)}
|
|
onMouseLeave={(e) => callbacks?.onColumnLeave?.(colIndex, e)}
|
|
/>
|
|
);
|
|
})}
|
|
</svg>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AbacusReact;
|