1+ const canvas = document . getElementById ( 'connections' ) ;
2+ const ctx = canvas . getContext ( '2d' ) ;
3+ const scene = document . getElementById ( 'scene-container' ) ;
4+ const motionBtn = document . getElementById ( 'toggle-motion' ) ;
5+ const searchInput = document . getElementById ( 'search-input' ) ;
6+
7+ // --- Configuration ---
8+ const config = {
9+ sphereRadius : 280 ,
10+ baseRotationSpeed : 0.002 ,
11+ mouseSensitivity : 0.00008 ,
12+ perspective : 800 ,
13+ connectionDistance : 120 ,
14+ isPaused : false
15+ } ;
16+
17+ const mediaQuery = window . matchMedia ( '(prefers-reduced-motion: reduce)' ) ;
18+ if ( mediaQuery . matches ) {
19+ config . isPaused = true ;
20+ motionBtn . textContent = "Resume Motion" ;
21+ motionBtn . setAttribute ( 'aria-pressed' , 'true' ) ;
22+ }
23+
24+ let links = [ ] ;
25+ let points = [ ] ;
26+ let width , height , cx , cy ;
27+ let mouseX = window . innerWidth / 2 ;
28+ let mouseY = window . innerHeight / 2 ;
29+ let isMouseOver = false ;
30+ let focusedPoint = null ;
31+ let searchedPoint = null ;
32+ let isSearchHovering = false ;
33+ let currentRotX = 0 ;
34+ let currentRotY = 0 ;
35+
36+ // --- Load Data ---
37+ fetch ( 'links.yaml' )
38+ . then ( response => response . text ( ) )
39+ . then ( yamlText => {
40+ links = jsyaml . load ( yamlText ) ;
41+ init ( ) ;
42+ } )
43+ . catch ( error => console . error ( 'Error loading links.yaml:' , error ) ) ;
44+
45+ class Point {
46+ constructor ( data , id , total ) {
47+ this . data = data ;
48+ const phi = Math . acos ( 1 - 2 * ( id + 0.5 ) / total ) ;
49+ const theta = Math . PI * ( 1 + Math . sqrt ( 5 ) ) * ( id + 0.5 ) ;
50+ this . x = config . sphereRadius * Math . sin ( phi ) * Math . cos ( theta ) ;
51+ this . y = config . sphereRadius * Math . sin ( phi ) * Math . sin ( theta ) ;
52+ this . z = config . sphereRadius * Math . cos ( phi ) ;
53+ this . screenX = 0 ;
54+ this . screenY = 0 ;
55+ this . alpha = 1 ;
56+
57+ this . element = document . createElement ( 'a' ) ;
58+ this . element . href = data . url ;
59+ this . element . className = 'node-link' ;
60+ this . element . target = "_blank" ;
61+ this . element . setAttribute ( 'aria-label' , `Visit ${ data . text } ` ) ;
62+
63+ this . element . innerHTML = `
64+ <div class="node-dot"></div>
65+ <span class="node-text">${ data . text } </span>
66+ ` ;
67+
68+ this . element . addEventListener ( 'focus' , ( ) => {
69+ focusedPoint = this ;
70+ config . isPaused = true ;
71+ } ) ;
72+
73+ this . element . addEventListener ( 'blur' , ( ) => {
74+ focusedPoint = null ;
75+ if ( ! mediaQuery . matches && motionBtn . innerText === "PAUSE MOTION" ) {
76+ config . isPaused = false ;
77+ }
78+ } ) ;
79+
80+ scene . appendChild ( this . element ) ;
81+ }
82+
83+ rotate ( angleX , angleY ) {
84+ let cosY = Math . cos ( angleY ) ;
85+ let sinY = Math . sin ( angleY ) ;
86+ let x1 = this . x * cosY - this . z * sinY ;
87+ let z1 = this . z * cosY + this . x * sinY ;
88+ let cosX = Math . cos ( angleX ) ;
89+ let sinX = Math . sin ( angleX ) ;
90+ let y1 = this . y * cosX - z1 * sinX ;
91+ let z2 = z1 * cosX + this . y * sinX ;
92+ this . x = x1 ;
93+ this . y = y1 ;
94+ this . z = z2 ;
95+ }
96+
97+ updateDOM ( ) {
98+ const scale = config . perspective / ( config . perspective - this . z ) ;
99+ this . screenX = this . x * scale ;
100+ this . screenY = this . y * scale ;
101+ this . alpha = Math . max ( 0.2 , ( this . z + config . sphereRadius ) / ( 2 * config . sphereRadius ) ) ;
102+
103+ this . element . style . transform = `translate3d(${ this . screenX } px, ${ this . screenY } px, 0) scale(${ scale } ) translate(-50%, -50%)` ;
104+ this . element . style . opacity = this . alpha ;
105+ this . element . style . zIndex = Math . floor ( this . z + config . sphereRadius ) + 100 ;
106+
107+ const blurAmount = Math . max ( 0 , ( config . sphereRadius - this . z ) / 80 ) ;
108+ this . element . style . filter = `blur(${ blurAmount } px)` ;
109+ this . element . style . visibility = 'visible' ;
110+ }
111+ }
112+
113+ function init ( ) {
114+ resize ( ) ;
115+ points = links . map ( ( link , i ) => new Point ( link , i , links . length ) ) ;
116+ loop ( ) ;
117+ }
118+
119+ function resize ( ) {
120+ width = canvas . width = window . innerWidth ;
121+ height = canvas . height = window . innerHeight ;
122+ cx = width / 2 ;
123+ cy = height / 2 ;
124+ const isPortrait = width < height ;
125+ const multiplier = isPortrait ? 0.42 : 0.35 ;
126+ config . sphereRadius = Math . min ( width , height ) * multiplier ;
127+ if ( ! isMouseOver ) {
128+ mouseX = cx ;
129+ mouseY = cy ;
130+ }
131+ }
132+
133+ function drawConnections ( ) {
134+ ctx . clearRect ( 0 , 0 , width , height ) ;
135+ ctx . save ( ) ;
136+ ctx . translate ( cx , cy ) ;
137+ ctx . strokeStyle = 'rgba(212, 175, 55, 0.2)' ;
138+ ctx . lineWidth = 1 ;
139+ for ( let i = 0 ; i < points . length ; i ++ ) {
140+ const p1 = points [ i ] ;
141+ if ( p1 . alpha < 0.3 ) continue ;
142+ for ( let j = i + 1 ; j < points . length ; j ++ ) {
143+ const p2 = points [ j ] ;
144+ if ( p2 . alpha < 0.3 ) continue ;
145+ const dx = p1 . x - p2 . x ;
146+ const dy = p1 . y - p2 . y ;
147+ const dz = p1 . z - p2 . z ;
148+ const dist = Math . sqrt ( dx * dx + dy * dy + dz * dz ) ;
149+ if ( dist < config . connectionDistance ) {
150+ ctx . beginPath ( ) ;
151+ ctx . moveTo ( p1 . screenX , p1 . screenY ) ;
152+ ctx . lineTo ( p2 . screenX , p2 . screenY ) ;
153+ ctx . stroke ( ) ;
154+ }
155+ }
156+ }
157+ ctx . restore ( ) ;
158+ }
159+
160+ searchInput . addEventListener ( 'mouseenter' , ( ) => { isSearchHovering = true ; } ) ;
161+ searchInput . addEventListener ( 'mouseleave' , ( ) => { isSearchHovering = false ; } ) ;
162+ searchInput . addEventListener ( 'touchstart' , ( ) => { isSearchHovering = true ; } , { passive : true } ) ;
163+ searchInput . addEventListener ( 'touchend' , ( ) => { setTimeout ( ( ) => isSearchHovering = false , 5000 ) ; } ) ;
164+
165+ searchInput . addEventListener ( 'input' , ( e ) => {
166+ const val = e . target . value . toLowerCase ( ) . trim ( ) ;
167+ points . forEach ( p => p . element . classList . remove ( 'is-searched' ) ) ;
168+ searchedPoint = null ;
169+ if ( val . length >= 2 ) {
170+ const match = points . find ( p => p . data . text . toLowerCase ( ) . includes ( val ) ) ;
171+ if ( match ) {
172+ searchedPoint = match ;
173+ match . element . classList . add ( 'is-searched' ) ;
174+ }
175+ }
176+ } ) ;
177+
178+ function loop ( ) {
179+ let targetRotX = 0 ;
180+ let targetRotY = 0 ;
181+ const activeTarget = focusedPoint || searchedPoint ;
182+ if ( activeTarget ) {
183+ const k = 0.05 ;
184+ targetRotY = Math . atan2 ( activeTarget . x , activeTarget . z ) * k ;
185+ targetRotX = Math . atan2 ( activeTarget . y , activeTarget . z ) * k ;
186+ } else if ( isSearchHovering ) {
187+ targetRotY = 0.005 ;
188+ targetRotX = 0 ;
189+ } else if ( ! config . isPaused ) {
190+ if ( isMouseOver ) {
191+ targetRotY = ( mouseX - cx ) * config . mouseSensitivity ;
192+ targetRotX = ( mouseY - cy ) * config . mouseSensitivity ;
193+ let nearestDist = Infinity ;
194+ points . forEach ( p => {
195+ if ( p . z > 0 ) {
196+ const dx = mouseX - ( cx + p . screenX ) ;
197+ const dy = mouseY - ( cy + p . screenY ) ;
198+ const d = Math . sqrt ( dx * dx + dy * dy ) ;
199+ if ( d < nearestDist ) nearestDist = d ;
200+ }
201+ } ) ;
202+ const brakeThreshold = 120 ;
203+ if ( nearestDist < brakeThreshold ) {
204+ const brakeFactor = Math . max ( 0.05 , nearestDist / brakeThreshold ) ;
205+ targetRotY *= brakeFactor ;
206+ targetRotX *= brakeFactor ;
207+ }
208+ } else {
209+ targetRotY = config . baseRotationSpeed ;
210+ }
211+ }
212+ const smoothness = 0.05 ;
213+ currentRotX += ( targetRotX - currentRotX ) * smoothness ;
214+ currentRotY += ( targetRotY - currentRotY ) * smoothness ;
215+ points . forEach ( p => {
216+ p . rotate ( currentRotX , currentRotY ) ;
217+ p . updateDOM ( ) ;
218+ } ) ;
219+ drawConnections ( ) ;
220+ requestAnimationFrame ( loop ) ;
221+ }
222+
223+ window . addEventListener ( 'resize' , resize ) ;
224+ window . addEventListener ( 'mousemove' , e => { mouseX = e . clientX ; mouseY = e . clientY ; isMouseOver = true ; } ) ;
225+ window . addEventListener ( 'mouseout' , ( ) => { isMouseOver = false ; } ) ;
226+ window . addEventListener ( 'touchmove' , e => { mouseX = e . touches [ 0 ] . clientX ; mouseY = e . touches [ 0 ] . clientY ; isMouseOver = true ; } , { passive : true } ) ;
227+ window . addEventListener ( 'touchend' , ( ) => { isMouseOver = false ; } ) ;
228+
229+ motionBtn . addEventListener ( 'click' , ( ) => {
230+ config . isPaused = ! config . isPaused ;
231+ motionBtn . textContent = config . isPaused ? "Resume Motion" : "Pause Motion" ;
232+ motionBtn . setAttribute ( 'aria-pressed' , config . isPaused ) ;
233+ } ) ;
234+
235+ window . addEventListener ( 'keydown' , ( e ) => {
236+ if ( e . key === 'Tab' ) {
237+ const interactiveElements = [ searchInput , motionBtn , ...document . querySelectorAll ( '.node-link' ) ] ;
238+ const first = interactiveElements [ 0 ] ;
239+ const last = interactiveElements [ interactiveElements . length - 1 ] ;
240+ if ( e . shiftKey ) {
241+ if ( document . activeElement === first ) { e . preventDefault ( ) ; last . focus ( ) ; }
242+ } else {
243+ if ( document . activeElement === last ) { e . preventDefault ( ) ; first . focus ( ) ; }
244+ }
245+ }
246+ } ) ;
0 commit comments