fix(tutorial): fix overlay rendering, arrow indicators, and bead visibility

Multiple fixes for tutorial regression issues:

- Fix overlay rendering: Changed from <g> to <foreignObject> to support
  DOM components (Radix UI Tooltip), fixing hydration errors
- Fix overlay positioning: Use calculateBeadPosition for accurate placement
- Fix arrow indicators: Reduce size, fix transform origin for center-based
  scaling, correct direction logic for heaven vs earth beads, add black
  stroke for contrast on yellow backgrounds, remove unnecessary circle
- Fix bead visibility: Implement proper opacity cascade (customStyle ->
  active -> hideInactiveBeads + hover), use opacity 0 instead of
  conditional rendering to enable hover detection
- Add abacus-level hover: Track hover state at abacus level and propagate
  to beads so inactive beads appear on hover over any part of abacus

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-04 17:49:12 -06:00
parent 9ba1824226
commit a80431608d
2 changed files with 157 additions and 33 deletions

View File

@@ -49,6 +49,9 @@ interface AnimatedBeadProps extends BeadComponentProps {
// 3D effects
enhanced3d?: 'none' | 'subtle' | 'realistic' | 'delightful'
columnIndex?: number
// Hover state from parent abacus
isAbacusHovered?: boolean
}
export function AbacusAnimatedBead({
@@ -73,9 +76,16 @@ export function AbacusAnimatedBead({
isCurrentStep,
enhanced3d = 'none',
columnIndex,
isAbacusHovered = false,
}: AnimatedBeadProps) {
// x, y are already calculated by AbacusSVGRenderer
// Track hover state for showing hidden inactive beads
const [isHovered, setIsHovered] = React.useState(false)
// Use abacus hover if provided, otherwise use individual bead hover
const effectiveHoverState = isAbacusHovered || isHovered
// Spring animation for position
const [{ springX, springY }, api] = useSpring(() => ({
springX: x,
@@ -200,10 +210,50 @@ export function AbacusAnimatedBead({
}
}
const opacity = bead.active ? (customStyle?.opacity ?? 1) : 0.3
// Calculate opacity based on state and settings
let opacity: number
if (customStyle?.opacity !== undefined) {
// Custom opacity always takes precedence
opacity = customStyle.opacity
} else if (bead.active) {
// Active beads are always full opacity
opacity = 1
} else if (hideInactiveBeads && effectiveHoverState) {
// Inactive beads that are hidden but being hovered show at low opacity
opacity = 0.3
} else if (hideInactiveBeads) {
// Inactive beads that are hidden and not hovered are invisible (handled below)
opacity = 0
} else {
// Inactive beads when hideInactiveBeads is false are full opacity
opacity = 1
}
const stroke = customStyle?.stroke || '#000'
const strokeWidth = customStyle?.strokeWidth || 0.5
// Debug logging for bead styling - only log yellow highlights
if (customStyle && customStyle.fill === '#fbbf24') {
console.log('🔴 YELLOW HIGHLIGHT BEAD RENDER:', JSON.stringify({
bead: {
type: bead.type,
position: bead.position,
placeValue: bead.placeValue,
active: bead.active,
},
size,
halfSize,
customStyle,
finalStyle: {
fillValue,
opacity,
stroke,
strokeWidth,
},
shape,
}, null, 2))
}
switch (shape) {
case 'diamond':
return (
@@ -258,28 +308,29 @@ export function AbacusAnimatedBead({
enableAnimation && showDirectionIndicator && direction ? animated.g : 'g'
// Build style object
// Show pointer cursor on hidden beads so users know they can interact
const shouldShowCursor = bead.active || !hideInactiveBeads || effectiveHoverState
const cursor = shouldShowCursor ? (enableGestures ? 'grab' : onClick ? 'pointer' : 'default') : 'default'
const beadStyle: any = enableAnimation
? {
transform: to(
[springX, springY],
(sx, sy) => `translate(${sx - getXOffset()}px, ${sy - getYOffset()}px)`
),
cursor: enableGestures ? 'grab' : onClick ? 'pointer' : 'default',
cursor,
touchAction: 'none' as const,
transition: 'opacity 0.2s ease-in-out',
pointerEvents: 'auto' as const, // Ensure hidden beads can still be hovered
}
: {
transform: `translate(${x - getXOffset()}px, ${y - getYOffset()}px)`,
cursor: enableGestures ? 'grab' : onClick ? 'pointer' : 'default',
cursor,
touchAction: 'none' as const,
transition: 'opacity 0.2s ease-in-out',
pointerEvents: 'auto' as const, // Ensure hidden beads can still be hovered
}
// Don't render inactive beads if hideInactiveBeads is true
if (!bead.active && hideInactiveBeads) {
return null
}
const handleClick = (event: React.MouseEvent) => {
// Prevent click if gesture was triggered
if (gestureStateRef.current.hasGestureTriggered) {
@@ -289,6 +340,16 @@ export function AbacusAnimatedBead({
onClick?.(bead, event)
}
const handleMouseEnter = (e: React.MouseEvent) => {
setIsHovered(true)
onMouseEnter?.(bead, e as any)
}
const handleMouseLeave = (e: React.MouseEvent) => {
setIsHovered(false)
onMouseLeave?.(bead, e as any)
}
return (
<>
<GElement
@@ -297,8 +358,8 @@ export function AbacusAnimatedBead({
style={beadStyle}
{...bind()}
onClick={handleClick}
onMouseEnter={(e) => onMouseEnter?.(bead, e as any)}
onMouseLeave={(e) => onMouseLeave?.(bead, e as any)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
ref={(el) => onRef?.(bead, el as any)}
>
{renderShape()}
@@ -316,35 +377,33 @@ export function AbacusAnimatedBead({
(sx, sy, pulse) => {
const centerX = shape === 'diamond' ? size * 0.7 : size / 2
const centerY = size / 2
return `translate(${sx - centerX}px, ${sy - centerY}px) scale(${pulse})`
// Scale from center: translate to position, then translate to center, scale, translate back
return `translate(${sx}px, ${sy}px) scale(${pulse}) translate(${-centerX}px, ${-centerY}px)`
}
),
pointerEvents: 'none' as const,
}
: {
transform: `translate(${x - (shape === 'diamond' ? size * 0.7 : size / 2)}px, ${y - size / 2}px) scale(1)`,
transform: `translate(${x}px, ${y}px) translate(${-(shape === 'diamond' ? size * 0.7 : size / 2)}px, ${-size / 2}px)`,
pointerEvents: 'none' as const,
}) as any
}
>
<circle
cx={shape === 'diamond' ? size * 0.7 : size / 2}
cy={size / 2}
r={size * 0.8}
fill="rgba(255, 165, 0, 0.3)"
stroke="orange"
strokeWidth="2"
/>
<text
x={shape === 'diamond' ? size * 0.7 : size / 2}
y={size / 2}
textAnchor="middle"
dy=".35em"
fontSize={size * 0.8}
fill="orange"
fontSize={size * 0.7}
fill="#fbbf24"
fontWeight="bold"
stroke="#000"
strokeWidth="1.5"
paintOrder="stroke"
>
{direction === 'activate' ? '↓' : '↑'}
{bead.type === 'heaven'
? (direction === 'activate' ? '↓' : '↑')
: (direction === 'activate' ? '↑' : '↓')}
</text>
</DirectionIndicatorG>
)}

View File

@@ -7,7 +7,7 @@ import NumberFlow from "@number-flow/react";
import { useAbacusConfig, getDefaultAbacusConfig } from "./AbacusContext";
import { playBeadSound } from "./soundManager";
import * as Abacus3DUtils from "./Abacus3DUtils";
import { calculateStandardDimensions } from "./AbacusUtils";
import { calculateStandardDimensions, calculateBeadPosition } from "./AbacusUtils";
import { AbacusSVGRenderer } from "./AbacusSVGRenderer";
import { AbacusAnimatedBead } from "./AbacusAnimatedBead";
import "./Abacus3D.css";
@@ -711,6 +711,13 @@ function mergeBeadStyles(
const beadStyles = customStyles.beads[columnIndex];
if (beadType === "heaven" && beadStyles.heaven) {
mergedStyle = { ...mergedStyle, ...beadStyles.heaven };
console.log('🎨 BEAD STYLE MERGE (heaven):', JSON.stringify({
columnIndex,
beadType,
isActive,
appliedStyle: beadStyles.heaven,
mergedStyle
}, null, 2))
}
if (
beadType === "earth" &&
@@ -718,6 +725,14 @@ function mergeBeadStyles(
beadStyles.earth?.[position]
) {
mergedStyle = { ...mergedStyle, ...beadStyles.earth[position] };
console.log('🎨 BEAD STYLE MERGE (earth):', JSON.stringify({
columnIndex,
beadType,
position,
isActive,
appliedStyle: beadStyles.earth[position],
mergedStyle
}, null, 2))
}
}
@@ -1860,6 +1875,9 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
// Place value editing - FRESH IMPLEMENTATION
const [activeColumn, setActiveColumn] = React.useState<number | null>(null);
// Track hover state for showing hidden inactive beads
const [isAbacusHovered, setIsAbacusHovered] = React.useState(false);
// Calculate current place values
const placeValues = React.useMemo(() => {
return columnStates.map(
@@ -2133,6 +2151,21 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
bead.active,
)
// Debug: log when we're computing styles for a bead that might have custom styles
if (customStyles?.beads?.[columnIndex]) {
console.log('🎯 CALCULATE EXTRA BEAD PROPS:', JSON.stringify({
bead: {
type: bead.type,
position: bead.position,
placeValue: bead.placeValue,
active: bead.active,
},
columnIndex,
customStylesBeads: customStyles.beads[columnIndex],
computedBeadStyle: beadStyle,
}, null, 2))
}
// Return extra props for AbacusAnimatedBead
return {
color: beadStyle.fill || baseProps.color,
@@ -2146,6 +2179,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
isCurrentStep: stepHighlight.isCurrentStep,
enhanced3d,
columnIndex,
isAbacusHovered,
}
}, [
finalConfig.animated,
@@ -2162,6 +2196,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
customStyles,
effectiveColumns,
handleGestureToggle,
isAbacusHovered,
])
return (
@@ -2190,6 +2225,8 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
setActiveColumn(null);
}
}}
onMouseEnter={() => setIsAbacusHovered(true)}
onMouseLeave={() => setIsAbacusHovered(false)}
>
<AbacusSVGRenderer
value={currentValue}
@@ -2315,25 +2352,53 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
const targetBeadPosition = overlay.target.beadPosition
if (targetColumn !== undefined && targetBeadType) {
const x = targetColumn * standardDims.rodSpacing + standardDims.rodSpacing / 2
// Convert columnIndex to placeValue
const placeValue = effectiveColumns - 1 - targetColumn
// Find the bead to get its Y position
// This is a simplified version - you may need to calculate the exact Y position
position = { x, y: standardDims.barY }
// Get column state to determine if bead is active
const columnState = abacusState[placeValue] || { heavenActive: false, earthActive: 0 }
// Determine if this specific bead is active
let isActive = false
if (targetBeadType === 'heaven') {
isActive = columnState.heavenActive
} else if (targetBeadType === 'earth' && targetBeadPosition !== undefined) {
isActive = targetBeadPosition < columnState.earthActive
}
// Create BeadPositionConfig for calculateBeadPosition
const beadConfig = {
type: targetBeadType,
active: isActive,
position: targetBeadPosition ?? 0,
placeValue: placeValue,
}
// Use calculateBeadPosition to get exact coordinates
position = calculateBeadPosition(beadConfig, standardDims, { earthActive: columnState.earthActive })
}
} else if (overlay.target.type === "coordinates") {
position = { x: overlay.target.x || 0, y: overlay.target.y || 0 }
}
return (
<g
<foreignObject
key={overlay.id}
transform={`translate(${position.x + (overlay.offset?.x || 0)}, ${position.y + (overlay.offset?.y || 0)})`}
x={position.x + (overlay.offset?.x || 0)}
y={position.y + (overlay.offset?.y || 0)}
width="300"
height="200"
style={{
overflow: 'visible',
pointerEvents: 'none',
...overlay.style
}}
className={overlay.className}
style={overlay.style}
>
{overlay.content}
</g>
<div style={{ pointerEvents: 'auto' }}>
{overlay.content}
</div>
</foreignObject>
)
})}
</AbacusSVGRenderer>