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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user