Skip to content

Commit fb3b398

Browse files
aymuos15claude
andcommitted
feat: 3D contribution graph on PRs tab — canvas-rendered with warm palette
Draws a horizontal 53×7 grid of 3D bars (weeks × days) using live data from github-contributions-api. Bar height maps to contribution count, with top/right face shading for depth. Hover shows date + count tooltip. Graph slides in smoothly when PRs tab is selected, hides on tab switch, and re-renders on theme toggle with matching warm tones. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a54abdf commit fb3b398

3 files changed

Lines changed: 182 additions & 2 deletions

File tree

index.html

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ <h1 class="name-link">Soumya Snigdha Kundu</h1>
9191
<button class="tab" data-category="misc">Misc</button>
9292
</div>
9393
<div class="updates-list" id="updates-list"></div>
94+
<div class="contrib-graph" id="contrib-graph">
95+
<div class="contrib-graph__inner">
96+
<canvas id="contrib-canvas" aria-label="GitHub contributions"></canvas>
97+
</div>
98+
</div>
99+
<div class="contrib-tip" id="contrib-tip"></div>
94100
</section>
95101

96102
<section id="gallery" class="section">
@@ -107,6 +113,6 @@ <h1 class="name-link">Soumya Snigdha Kundu</h1>
107113
<span class="footer-info">namma Chennai | London | soumya_snigdha.kundu-at-kcl.ac.uk</span>
108114
</footer>
109115

110-
<script src="script.js"></script>
116+
<script src="script.js"></script>
111117
</body>
112118
</html>

script.js

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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');
2122
const navToggle = document.querySelector('.nav-toggle');
2223
const updatesList = document.getElementById('updates-list');
2324
const 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
26154
navToggle.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
});

styles/components/news.css

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,47 @@
104104
min-width: 55px;
105105
}
106106
}
107+
108+
/* Contribution graph — 3D canvas, PRs tab */
109+
.contrib-graph {
110+
overflow: hidden;
111+
max-height: 0;
112+
transition: max-height 0.6s cubic-bezier(0.22, 1, 0.36, 1);
113+
}
114+
115+
.contrib-graph.visible {
116+
max-height: 500px;
117+
}
118+
119+
.contrib-graph__inner {
120+
padding-top: 1rem;
121+
opacity: 0;
122+
transform: translateY(14px);
123+
transition: opacity 0.45s ease 0.2s, transform 0.55s cubic-bezier(0.22, 1, 0.36, 1) 0.2s;
124+
}
125+
126+
.contrib-graph.visible .contrib-graph__inner {
127+
opacity: 1;
128+
transform: translateY(0);
129+
}
130+
131+
.contrib-graph canvas {
132+
max-width: 100%;
133+
display: block;
134+
cursor: crosshair;
135+
}
136+
137+
.contrib-tip {
138+
display: none;
139+
position: fixed;
140+
z-index: 300;
141+
font-family: var(--font-mono);
142+
font-size: 0.62rem;
143+
letter-spacing: 0.05em;
144+
color: var(--text);
145+
background: var(--bg);
146+
border: 1px solid var(--border);
147+
padding: 0.25rem 0.55rem;
148+
pointer-events: none;
149+
white-space: nowrap;
150+
}

0 commit comments

Comments
 (0)