@@ -12,6 +12,7 @@ document.addEventListener('DOMContentLoaded', () => {
1212 const next = current === 'light' ? 'dark' : 'light' ;
1313 document . documentElement . setAttribute ( 'data-theme' , next ) ;
1414 localStorage . setItem ( 'theme-preference' , next ) ;
15+ if ( contribGraph . classList . contains ( 'visible' ) ) _renderCanvas ( ) ;
1516 } ) ;
1617} ) ;
1718
@@ -21,6 +22,133 @@ const navLinks = document.querySelectorAll('.nav-links a');
2122const navToggle = document . querySelector ( '.nav-toggle' ) ;
2223const updatesList = document . getElementById ( 'updates-list' ) ;
2324const tabs = document . querySelectorAll ( '.tab' ) ;
25+ const contribGraph = document . getElementById ( 'contrib-graph' ) ;
26+ const contribCanvas = document . getElementById ( 'contrib-canvas' ) ;
27+ const contribTip = document . getElementById ( 'contrib-tip' ) ;
28+
29+ // ── 3D isometric contribution graph ──────────────────────────────────────────
30+ let _weeks = [ ] , _graphReady = false ;
31+
32+ function _colors ( ) {
33+ const dark = document . documentElement . getAttribute ( 'data-theme' ) === 'dark' ;
34+ return dark
35+ ? [ '#1e1c18' , '#4a3520' , '#8a6530' , '#c4a060' , '#f0d090' ]
36+ : [ '#e8e3da' , '#c4a070' , '#a07840' , '#7a5520' , '#4a3010' ] ;
37+ }
38+
39+ function _shade ( hex , f ) {
40+ const n = parseInt ( hex . slice ( 1 ) , 16 ) ;
41+ return `rgb(${ Math . min ( 255 , ~ ~ ( ( ( n >> 16 ) & 255 ) * f ) ) } ,${ Math . min ( 255 , ~ ~ ( ( ( n >> 8 ) & 255 ) * f ) ) } ,${ Math . min ( 255 , ~ ~ ( ( n & 255 ) * f ) ) } )` ;
42+ }
43+
44+ function _renderCanvas ( ) {
45+ if ( ! _weeks . length ) return ;
46+ const pal = _colors ( ) ;
47+ const CW = 9 , GAP = 2 , PX = CW + GAP , PY = CW + GAP ;
48+ const MAX_H = 18 , MIN_H = 1 , DX = 3 , DY = 3 ;
49+ const nC = _weeks . length , nR = 7 ;
50+ const maxN = Math . max ( 1 , ..._weeks . flat ( ) . filter ( Boolean ) . map ( d => d . count ) ) ;
51+
52+ const OX = 4 , OY = MAX_H + DY + 4 ;
53+ const W = OX + nC * PX + DX + 4 ;
54+ const H = OY + nR * PY + 4 ;
55+
56+ const dpr = window . devicePixelRatio || 1 ;
57+ contribCanvas . width = Math . ceil ( W * dpr ) ;
58+ contribCanvas . height = Math . ceil ( H * dpr ) ;
59+ contribCanvas . style . width = W + 'px' ;
60+ contribCanvas . style . height = H + 'px' ;
61+
62+ const ctx = contribCanvas . getContext ( '2d' ) ;
63+ ctx . scale ( dpr , dpr ) ;
64+ ctx . clearRect ( 0 , 0 , W , H ) ;
65+
66+ const hits = [ ] ;
67+ for ( let row = 0 ; row < nR ; row ++ ) {
68+ for ( let col = 0 ; col < nC ; col ++ ) {
69+ const day = _weeks [ col ] ?. [ row ] ;
70+ const level = day ?. level ?? 0 ;
71+ const count = day ?. count ?? 0 ;
72+ const h = count > 0 ? MIN_H + ( count / maxN ) * ( MAX_H - MIN_H ) : MIN_H ;
73+
74+ const sx = OX + col * PX ;
75+ const by = OY + row * PY + CW ;
76+ const ty = by - h ;
77+ const c = pal [ level ] ;
78+
79+ // front face
80+ ctx . fillStyle = c ;
81+ ctx . fillRect ( sx , ty , CW , h ) ;
82+ // top face
83+ ctx . beginPath ( ) ;
84+ ctx . moveTo ( sx , ty ) ; ctx . lineTo ( sx + CW , ty ) ;
85+ ctx . lineTo ( sx + CW + DX , ty - DY ) ; ctx . lineTo ( sx + DX , ty - DY ) ;
86+ ctx . closePath ( ) ; ctx . fillStyle = _shade ( c , 1.18 ) ; ctx . fill ( ) ;
87+ // right face
88+ ctx . beginPath ( ) ;
89+ ctx . moveTo ( sx + CW , ty ) ; ctx . lineTo ( sx + CW + DX , ty - DY ) ;
90+ ctx . lineTo ( sx + CW + DX , by - DY ) ; ctx . lineTo ( sx + CW , by ) ;
91+ ctx . closePath ( ) ; ctx . fillStyle = _shade ( c , 0.65 ) ; ctx . fill ( ) ;
92+
93+ hits . push ( { sx, ty, h, day } ) ;
94+ }
95+ }
96+ contribCanvas . _hits = hits ;
97+ contribCanvas . _CW = CW ;
98+ }
99+
100+ contribCanvas . addEventListener ( 'mousemove' , e => {
101+ const hits = contribCanvas . _hits ;
102+ if ( ! hits ) return ;
103+ const CW = contribCanvas . _CW ;
104+ const rect = contribCanvas . getBoundingClientRect ( ) ;
105+ const sc = ( parseFloat ( contribCanvas . style . width ) || rect . width ) / rect . width ;
106+ const mx = ( e . clientX - rect . left ) * sc ;
107+ const my = ( e . clientY - rect . top ) * sc ;
108+
109+ let hit = null ;
110+ for ( let i = hits . length - 1 ; i >= 0 ; i -- ) {
111+ const { sx, ty, h } = hits [ i ] ;
112+ if ( mx >= sx && mx < sx + CW && my >= ty && my < ty + h ) { hit = hits [ i ] ; break ; }
113+ }
114+
115+ if ( hit ?. day ) {
116+ const { count, date } = hit . day ;
117+ const label = new Date ( date + 'T12:00:00' ) . toLocaleDateString ( 'en-US' , { weekday : 'short' , month : 'short' , day : 'numeric' } ) ;
118+ contribTip . textContent = `${ count || 'no' } contribution${ count !== 1 ? 's' : '' } · ${ label } ` ;
119+ contribTip . style . display = 'block' ;
120+ contribTip . style . left = ( e . clientX + 14 ) + 'px' ;
121+ contribTip . style . top = ( e . clientY - 38 ) + 'px' ;
122+ } else {
123+ contribTip . style . display = 'none' ;
124+ }
125+ } ) ;
126+ contribCanvas . addEventListener ( 'mouseleave' , ( ) => { contribTip . style . display = 'none' ; } ) ;
127+
128+ async function _loadContribs ( ) {
129+ const res = await window . fetch ( 'https://github-contributions-api.jogruber.de/v4/aymuos15?y=last' ) ;
130+ const days = ( await res . json ( ) ) . contributions || [ ] ;
131+ const weeks = [ ] ;
132+ let week = new Array ( 7 ) . fill ( null ) ;
133+ days . forEach ( day => {
134+ const dow = new Date ( day . date + 'T12:00:00' ) . getDay ( ) ;
135+ if ( dow === 0 && week . some ( Boolean ) ) { weeks . push ( [ ...week ] ) ; week = new Array ( 7 ) . fill ( null ) ; }
136+ week [ dow ] = day ;
137+ } ) ;
138+ if ( week . some ( Boolean ) ) weeks . push ( week ) ;
139+ return weeks ;
140+ }
141+
142+ function showContribGraph ( ) {
143+ contribGraph . classList . add ( 'visible' ) ;
144+ if ( _graphReady ) { _renderCanvas ( ) ; return ; }
145+ _loadContribs ( ) . then ( w => { _weeks = w ; _graphReady = true ; _renderCanvas ( ) ; } )
146+ . catch ( err => console . warn ( 'Contrib graph:' , err ) ) ;
147+ }
148+ function hideContribGraph ( ) {
149+ contribGraph . classList . remove ( 'visible' ) ;
150+ contribTip . style . display = 'none' ;
151+ }
24152
25153// Mobile nav dropdown
26154navToggle . addEventListener ( 'click' , ( ) => {
@@ -142,7 +270,7 @@ function renderUpdates(category, resetScroll) {
142270 ? updates . filter ( u => u . category !== 'pr' )
143271 : updates . filter ( u => u . category === category ) ;
144272 updatesList . innerHTML = filtered . map ( ( u , i ) =>
145- `<div class="update-item" style="animation-delay: ${ i * 30 } ms"><span class="update-date">${ u . date } </span><span class="update-desc">${ u . description } </span></div>`
273+ `<div class="update-item" data-category=" ${ u . category } " style="animation-delay: ${ i * 30 } ms"><span class="update-date">${ u . date } </span><span class="update-desc">${ u . description } </span></div>`
146274 ) . join ( '' ) ;
147275
148276 if ( resetScroll ) {
@@ -164,12 +292,14 @@ tabs.forEach(tab => {
164292 tab . classList . add ( 'active' ) ;
165293
166294 updatesList . classList . add ( 'fading' ) ;
295+ hideContribGraph ( ) ;
167296
168297 setTimeout ( ( ) => {
169298 renderUpdates ( tab . dataset . category , true ) ;
170299 updatesList . classList . remove ( 'fading' ) ;
171300 colorizeLinks ( ) ;
172301 tabSwitching = false ;
302+ if ( tab . dataset . category === 'pr' ) showContribGraph ( ) ;
173303 } , 300 ) ;
174304 } ) ;
175305} ) ;
0 commit comments