4848 } , 800 ) ;
4949} ) ( ) ;
5050
51- // ── 5. Sidebar: hidden during hero+timeline, appears at projects section
51+ // ── 5. Timeline: one continuous S-snake dotted line, card border to card border ──
52+ ( function ( ) {
53+ function buildTimelinePath ( ) {
54+ const container = document . querySelector ( '.timeline-container' ) ;
55+ const grid = document . querySelector ( '.timeline-grid' ) ;
56+ if ( ! container || ! grid ) return ;
57+
58+ const old = container . querySelector ( '.timeline-svg' ) ;
59+ if ( old ) old . remove ( ) ;
60+
61+ const items = Array . from ( grid . querySelectorAll ( '.timeline-item' ) ) ;
62+ if ( items . length < 2 ) return ;
63+
64+ const cRect = container . getBoundingClientRect ( ) ;
65+ const svgW = cRect . width ;
66+ const svgH = cRect . height ;
67+
68+ // Collect the anchor point on the card border facing the centre for each item.
69+ // Odd items (i=0,2,4): card is on LEFT → anchor = right-centre border of card
70+ // Even items (i=1,3,5): card is on RIGHT → anchor = left-centre border of card
71+ const anchors = items . map ( ( item , i ) => {
72+ const cardEl = item . querySelector ( '.timeline-content' ) ;
73+ if ( ! cardEl ) return null ;
74+ const cr = cardEl . getBoundingClientRect ( ) ;
75+ const isLeft = ( i % 2 === 0 ) ;
76+ return {
77+ x : isLeft ? ( cr . right - cRect . left ) : ( cr . left - cRect . left ) ,
78+ y : cr . top - cRect . top + cr . height / 2
79+ } ;
80+ } ) . filter ( Boolean ) ;
81+
82+ if ( anchors . length < 2 ) return ;
83+
84+ // Build ONE continuous cubic Bézier path through all anchors.
85+ // The trick for a smooth S with no breaks:
86+ // At every interior anchor the tangent must be continuous.
87+ // We achieve this by using the same horizontal pull direction and
88+ // making control points symmetric around each anchor.
89+ //
90+ // For segment i → i+1:
91+ // a = anchors[i], b = anchors[i+1]
92+ // The path travels horizontally out of a (CP1 at same Y as a),
93+ // crosses over, then arrives horizontally into b (CP2 at same Y as b).
94+ // Pull distance = half the horizontal span between a and b.
95+ //
96+ // To keep the path CONTINUOUS and SMOOTH at every junction we use
97+ // a Catmull-Rom → cubic Bézier conversion approach:
98+ // each segment's control points are tangent-matched to neighbours.
99+
100+ // Compute tangent at each anchor using Catmull-Rom rule
101+ // (for the endpoints we mirror the first/last interior tangent)
102+ function catmullTangent ( prev , curr , next ) {
103+ return {
104+ tx : ( next . x - prev . x ) * 0.5 ,
105+ ty : ( next . y - prev . y ) * 0.5
106+ } ;
107+ }
108+
109+ const n = anchors . length ;
110+ const tan = anchors . map ( ( _ , i ) => {
111+ const prev = anchors [ Math . max ( i - 1 , 0 ) ] ;
112+ const next = anchors [ Math . min ( i + 1 , n - 1 ) ] ;
113+ return catmullTangent ( prev , anchors [ i ] , next ) ;
114+ } ) ;
115+
116+ // Override tangents to be PURELY HORIZONTAL at each card border.
117+ // This ensures the line exits/enters every card border at 90° (clean T-junction).
118+ // We keep the sign (direction) from Catmull-Rom but zero out the Y component.
119+ const horizontalTan = anchors . map ( ( a , i ) => {
120+ const raw = tan [ i ] ;
121+ // Magnitude = half the horizontal gap to the nearest neighbour
122+ const prev = anchors [ Math . max ( i - 1 , 0 ) ] ;
123+ const next = anchors [ Math . min ( i + 1 , n - 1 ) ] ;
124+ const mag = Math . abs ( next . x - prev . x ) * 1 ;
125+ const sign = raw . tx >= 0 ? 1 : - 1 ;
126+ return { tx : sign * mag , ty : 0 } ;
127+ } ) ;
128+
129+ let d = `M ${ anchors [ 0 ] . x . toFixed ( 2 ) } ${ anchors [ 0 ] . y . toFixed ( 2 ) } ` ;
130+
131+ for ( let i = 0 ; i < n - 1 ; i ++ ) {
132+ const a = anchors [ i ] ;
133+ const b = anchors [ i + 1 ] ;
134+ const t0 = horizontalTan [ i ] ;
135+ const t1 = horizontalTan [ i + 1 ] ;
136+
137+ // Cubic Bézier from Catmull-Rom:
138+ // CP1 = a + t0/3, CP2 = b - t1/3
139+ const cp1x = a . x + t0 . tx ;
140+ const cp1y = a . y + t0 . ty ;
141+ const cp2x = b . x - t1 . tx ;
142+ const cp2y = b . y - t1 . ty ;
143+
144+ d += ` C ${ cp1x . toFixed ( 2 ) } ${ cp1y . toFixed ( 2 ) } , ${ cp2x . toFixed ( 2 ) } ${ cp2y . toFixed ( 2 ) } , ${ b . x . toFixed ( 2 ) } ${ b . y . toFixed ( 2 ) } ` ;
145+ }
146+
147+ const svg = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'svg' ) ;
148+ svg . setAttribute ( 'class' , 'timeline-svg' ) ;
149+ svg . setAttribute ( 'viewBox' , `0 0 ${ svgW } ${ svgH } ` ) ;
150+ svg . setAttribute ( 'aria-hidden' , 'true' ) ;
151+ svg . style . cssText = `position:absolute;left:0;top:0;width:100%;height:${ svgH } px;pointer-events:none;z-index:2;overflow:visible;` ;
152+
153+ // Dim ghost track
154+ const track = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'path' ) ;
155+ track . setAttribute ( 'class' , 'timeline-svg-track' ) ;
156+ track . setAttribute ( 'd' , d ) ;
157+ track . setAttribute ( 'fill' , 'none' ) ;
158+
159+ // Glowing accent fill — animated draw-in
160+ const fill = document . createElementNS ( 'http://www.w3.org/2000/svg' , 'path' ) ;
161+ fill . setAttribute ( 'class' , 'timeline-svg-fill' ) ;
162+ fill . setAttribute ( 'd' , d ) ;
163+ fill . setAttribute ( 'fill' , 'none' ) ;
164+
165+ svg . appendChild ( track ) ;
166+ svg . appendChild ( fill ) ;
167+ container . insertBefore ( svg , container . firstChild ) ;
168+
169+ // Scroll-triggered draw-in animation
170+ requestAnimationFrame ( ( ) => {
171+ const totalLen = fill . getTotalLength ? fill . getTotalLength ( ) : 5000 ;
172+ fill . style . strokeDasharray = '5 16' ;
173+ fill . style . strokeDashoffset = String ( totalLen ) ;
174+ fill . style . transition = 'none' ;
175+
176+ const section = document . getElementById ( 'timelineSection' ) ;
177+ const io2 = new IntersectionObserver ( entries => {
178+ if ( entries [ 0 ] . isIntersecting ) {
179+ requestAnimationFrame ( ( ) => {
180+ fill . style . transition = 'stroke-dashoffset 2.4s cubic-bezier(0.16,1,0.3,1)' ;
181+ fill . style . strokeDashoffset = '0' ;
182+ } ) ;
183+ io2 . disconnect ( ) ;
184+ }
185+ } , { threshold : 0.05 } ) ;
186+ if ( section ) io2 . observe ( section ) ;
187+ } ) ;
188+ }
189+
190+ if ( document . readyState === 'complete' ) {
191+ buildTimelinePath ( ) ;
192+ } else {
193+ window . addEventListener ( 'load' , buildTimelinePath ) ;
194+ }
195+
196+ let resizeTimer ;
197+ window . addEventListener ( 'resize' , ( ) => {
198+ clearTimeout ( resizeTimer ) ;
199+ resizeTimer = setTimeout ( buildTimelinePath , 150 ) ;
200+ } ) ;
201+ } ) ( ) ;
202+
203+ // ── 6. Sidebar: hidden during hero+timeline, appears at projects section
52204( function ( ) {
53205 const projectsSection = document . getElementById ( 'projectsSection' ) ;
54206
55- // Not the index page (sub-pages have sidebar-active on <body> already)
56207 if ( ! projectsSection ) {
57208 document . body . classList . add ( 'sidebar-active' ) ;
58209 return ;
59210 }
60211
61- // Index page: start with sidebar hidden
62212 document . body . classList . remove ( 'sidebar-active' ) ;
63213
64214 const THRESHOLD = 0.05 ;
65215
66216 const io = new IntersectionObserver ( entries => {
67217 entries . forEach ( entry => {
68218 if ( entry . isIntersecting ) {
69- // Projects section in view → show sidebar
70219 document . body . classList . add ( 'sidebar-active' ) ;
71220 } else {
72- // Scrolled back above projects → hide sidebar
73221 const rect = projectsSection . getBoundingClientRect ( ) ;
74222 if ( rect . top > 0 ) document . body . classList . remove ( 'sidebar-active' ) ;
75223 }
76224 } ) ;
77225 } , { threshold : THRESHOLD } ) ;
78226
79227 io . observe ( projectsSection ) ;
80- } ) ( ) ;
228+ } ) ( ) ;
0 commit comments