1- import React , { useState } from 'react' ;
1+ import React , { useState , useEffect , useRef } from 'react' ;
22import styles from './UShapeAttentionCurve.module.css' ;
33
44interface UShapeAttentionCurveProps {
@@ -9,11 +9,14 @@ export default function UShapeAttentionCurve({
99 initialContextFill = 60 ,
1010} : UShapeAttentionCurveProps ) : JSX . Element {
1111 const [ contextFill , setContextFill ] = useState ( initialContextFill ) ;
12+ const [ animatedPath , setAnimatedPath ] = useState < string > ( '' ) ;
13+ const previousPathRef = useRef < string > ( '' ) ;
14+ const animationFrameRef = useRef < number | null > ( null ) ;
1215
1316 // SVG dimensions
1417 const width = 800 ;
1518 const height = 300 ;
16- const padding = 60 ;
19+ const padding = 70 ;
1720
1821 // Calculate curve parameters based on context fill percentage
1922 // More context = deeper U (worse middle attention)
@@ -35,9 +38,105 @@ export default function UShapeAttentionCurve({
3538 C ${ middleX + 100 } ,${ middleY } ${ endX - 100 } ,${ startY } ${ endX } ,${ endY }
3639 ` . trim ( ) ;
3740
41+ // Animate path morphing for Safari compatibility
42+ useEffect ( ( ) => {
43+ // Initialize on first render
44+ if ( ! previousPathRef . current ) {
45+ previousPathRef . current = curvePath ;
46+ setAnimatedPath ( curvePath ) ;
47+ return ;
48+ }
49+
50+ // If path hasn't changed, skip animation
51+ if ( previousPathRef . current === curvePath ) {
52+ return ;
53+ }
54+
55+ // Parse path coordinates using regex
56+ const parsePathCoords = ( path : string ) : number [ ] => {
57+ const matches = path . match ( / [ \d . ] + / g) ;
58+ return matches ? matches . map ( Number ) : [ ] ;
59+ } ;
60+
61+ const startCoords = parsePathCoords ( previousPathRef . current ) ;
62+ const endCoords = parsePathCoords ( curvePath ) ;
63+
64+ // Animation parameters
65+ const duration = 600 ; // 600ms to match CSS timing
66+ const startTime = performance . now ( ) ;
67+
68+ // Cubic bezier easing function matching CSS cubic-bezier(0.4, 0, 0.2, 1)
69+ const cubicBezier = (
70+ p1x : number ,
71+ p1y : number ,
72+ p2x : number ,
73+ p2y : number
74+ ) => {
75+ // Binary search to find t for given x
76+ const getTForX = ( x : number ) : number => {
77+ let t = x ;
78+ for ( let i = 0 ; i < 8 ; i ++ ) {
79+ const slope =
80+ 3 * p1x * ( 1 - t ) ** 2 +
81+ 6 * ( p2x - p1x ) * t * ( 1 - t ) +
82+ 3 * ( 1 - p2x ) * t ** 2 ;
83+ if ( slope === 0 ) break ;
84+ const currentX =
85+ 3 * ( 1 - t ) ** 2 * t * p1x + 3 * ( 1 - t ) * t ** 2 * p2x + t ** 3 ;
86+ t -= ( currentX - x ) / slope ;
87+ }
88+ return t ;
89+ } ;
90+
91+ return ( x : number ) : number => {
92+ if ( x === 0 || x === 1 ) return x ;
93+ const t = getTForX ( x ) ;
94+ return 3 * ( 1 - t ) ** 2 * t * p1y + 3 * ( 1 - t ) * t ** 2 * p2y + t ** 3 ;
95+ } ;
96+ } ;
97+
98+ const easing = cubicBezier ( 0.4 , 0 , 0.2 , 1 ) ;
99+
100+ // Animation loop
101+ const animate = ( currentTime : number ) => {
102+ const elapsed = currentTime - startTime ;
103+ const progress = Math . min ( elapsed / duration , 1 ) ;
104+ const easedProgress = easing ( progress ) ;
105+
106+ // Interpolate coordinates
107+ const interpolatedCoords = startCoords . map ( ( start , i ) => {
108+ const end = endCoords [ i ] ;
109+ return start + ( end - start ) * easedProgress ;
110+ } ) ;
111+
112+ // Reconstruct path string
113+ const [ m1 , m2 , c1 , c2 , c3 , c4 , c5 , c6 , c7 , c8 , c9 , c10 , c11 , c12 ] =
114+ interpolatedCoords ;
115+ const interpolatedPath = `M ${ m1 } ,${ m2 } C ${ c1 } ,${ c2 } ${ c3 } ,${ c4 } ${ c5 } ,${ c6 } C ${ c7 } ,${ c8 } ${ c9 } ,${ c10 } ${ c11 } ,${ c12 } ` ;
116+
117+ setAnimatedPath ( interpolatedPath ) ;
118+
119+ if ( progress < 1 ) {
120+ animationFrameRef . current = requestAnimationFrame ( animate ) ;
121+ } else {
122+ previousPathRef . current = curvePath ;
123+ }
124+ } ;
125+
126+ // Start animation
127+ animationFrameRef . current = requestAnimationFrame ( animate ) ;
128+
129+ // Cleanup on unmount or when curvePath changes
130+ return ( ) => {
131+ if ( animationFrameRef . current !== null ) {
132+ cancelAnimationFrame ( animationFrameRef . current ) ;
133+ }
134+ } ;
135+ } , [ curvePath ] ) ;
136+
38137 // Area fill path (curve + bottom edge for filled area)
39138 const areaPath = `
40- ${ curvePath }
139+ ${ animatedPath || curvePath }
41140 L ${ endX } ,${ height - padding }
42141 L ${ startX } ,${ height - padding }
43142 Z
@@ -124,7 +223,7 @@ export default function UShapeAttentionCurve({
124223
125224 { /* The U-shaped curve line */ }
126225 < path
127- d = { curvePath }
226+ d = { animatedPath || curvePath }
128227 fill = "none"
129228 stroke = { `url(#${ gradientId } )` }
130229 strokeWidth = "4"
@@ -223,9 +322,10 @@ export default function UShapeAttentionCurve({
223322 </ text >
224323 < text
225324 x = { padding - 40 }
226- y = { middleY }
325+ y = { padding }
227326 textAnchor = "middle"
228327 className = { styles . axisLabel }
328+ style = { { transform : `translateY(${ middleY - padding } px)` } }
229329 >
230330 Low
231331 </ text >
0 commit comments