// Renders a GitHub-like activity heatmap given a container and data // Usage: ActivityHeatmap.render(containerId, data) // data: { 'YYYY-MM-DD': count, ... } const ActivityHeatmap = { githubColors: [ '#ebedf0', // 0 '#9be9a8', // 1 '#40c463', // 2 '#30a14e', // 3 '#216e39' // 4+ ], getColor(count) { if (count === 0) return this.githubColors[0]; if (count === 1) return this.githubColors[1]; if (count <= 3) return this.githubColors[2]; if (count <= 6) return this.githubColors[3]; return this.githubColors[4]; }, render(containerId, data) { const container = document.getElementById(containerId); if (!container) return; container.innerHTML = ''; // Build an array of all days in the past year const today = new Date(); const startDate = new Date(today); startDate.setFullYear(today.getFullYear() - 1); // Always start on Monday const dayOfWeek = startDate.getDay(); // 0=Sun, 1=Mon, ..., 6=Sat const daysToMonday = (dayOfWeek === 0) ? 1 : (dayOfWeek === 1 ? 0 : 8 - dayOfWeek); startDate.setDate(startDate.getDate() + daysToMonday - (dayOfWeek === 0 ? 7 : 0)); const days = []; let d = new Date(startDate); while (d <= today) { days.push(new Date(d)); d.setDate(d.getDate() + 1); } // Pad at end so last week is always full (7 days) while (days.length % 7 !== 0) { days.push(null); // null means empty block } // Group days into weeks const weeks = []; for (let i = 0; i < days.length; i += 7) { weeks.push(days.slice(i, i + 7)); } // Set cell size and gap before using them const cellSize = 14; const cellGap = 3; // Prepare month labels (x-axis), skip the first detected month const monthLabels = []; let prevMonth = null; let isFirstMonth = true; weeks.forEach((week, x) => { const firstDay = week[0]; if (firstDay) { const month = firstDay.getMonth(); if (month !== prevMonth) { if (!isFirstMonth) { monthLabels.push({ label: firstDay.toLocaleString('default', { month: 'short' }), x: x * (cellSize + cellGap) }); } else { isFirstMonth = false; } prevMonth = month; } } }); // Prepare weekday labels (y-axis) - y=0 is Monday const weekdayLabels = [ { label: 'Mon', y: 0 * (cellSize + cellGap) }, { label: 'Wed', y: 2 * (cellSize + cellGap) }, { label: 'Fri', y: 4 * (cellSize + cellGap) } ]; // Build SVG const leftPad = 32; // Space for weekday labels const topPad = 18; // Space for month labels const width = leftPad + weeks.length * (cellSize + cellGap); const height = topPad + 7 * (cellSize + cellGap); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', width); svg.setAttribute('height', height); svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.style.display = 'block'; // Month labels (top x-axis) monthLabels.forEach(({ label, x }) => { const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', leftPad + x + cellSize / 2); text.setAttribute('y', 12); text.setAttribute('text-anchor', 'middle'); text.setAttribute('font-size', '11'); text.setAttribute('fill', '#888'); text.textContent = label; svg.appendChild(text); }); // Weekday labels (left y-axis) weekdayLabels.forEach(({ label, y }) => { const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); text.setAttribute('x', leftPad - 6); text.setAttribute('y', topPad + y + cellSize - 3); text.setAttribute('text-anchor', 'end'); text.setAttribute('font-size', '11'); text.setAttribute('fill', '#888'); text.textContent = label; svg.appendChild(text); }); // Draw heatmap cells // Tooltip: only one in DOM let tooltip = document.getElementById('activity-heatmap-tooltip'); if (!tooltip) { tooltip = document.createElement('div'); tooltip.id = 'activity-heatmap-tooltip'; Object.assign(tooltip.style, { position: 'fixed', pointerEvents: 'none', background: '#fff', color: '#222', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.10)', padding: '7px 14px', fontSize: '13px', fontFamily: 'system-ui, sans-serif', visibility: 'hidden', zIndex: 1000, opacity: 0, transition: 'opacity 0.15s', }); document.body.appendChild(tooltip); } // Batch SVG elements for performance const frag = document.createDocumentFragment(); weeks.forEach((week, x) => { week.forEach((date, y) => { if (!date) { // Draw empty block const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', leftPad + x * (cellSize + cellGap)); rect.setAttribute('y', topPad + y * (cellSize + cellGap)); rect.setAttribute('width', cellSize); rect.setAttribute('height', cellSize); rect.setAttribute('fill', '#fff'); rect.setAttribute('rx', 3); rect.setAttribute('ry', 3); frag.appendChild(rect); return; } const iso = date.toISOString().slice(0, 10); const count = data[iso] || 0; const color = ActivityHeatmap.getColor(count); const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('x', leftPad + x * (cellSize + cellGap)); rect.setAttribute('y', topPad + y * (cellSize + cellGap)); rect.setAttribute('width', cellSize); rect.setAttribute('height', cellSize); rect.setAttribute('fill', color); rect.setAttribute('rx', 3); rect.setAttribute('ry', 3); rect.setAttribute('data-date', iso); rect.setAttribute('data-count', count); rect.setAttribute('tabindex', '0'); rect.setAttribute('aria-label', `${count} play${count === 1 ? '' : 's'} on ${date.toLocaleDateString(undefined, {weekday:'long', year:'numeric', month:'short', day:'numeric'})}`); rect.style.cursor = 'pointer'; rect.removeAttribute('title'); // Tooltip logic const showTooltip = (e) => { tooltip.style.visibility = 'visible'; tooltip.style.opacity = '1'; const weekday = date.toLocaleDateString(undefined, { weekday: 'short' }); tooltip.innerHTML = `${count} play${count === 1 ? '' : 's'}
${weekday}, ${iso}`; }; let rafId; const moveTooltip = (e) => { if (rafId) cancelAnimationFrame(rafId); rafId = requestAnimationFrame(() => { const pad = 12; let x = e.clientX + pad; let y = e.clientY + pad; if (x + tooltip.offsetWidth > window.innerWidth) x = window.innerWidth - tooltip.offsetWidth - pad; if (y + tooltip.offsetHeight > window.innerHeight) y = window.innerHeight - tooltip.offsetHeight - pad; tooltip.style.left = x + 'px'; tooltip.style.top = y + 'px'; }); }; const hideTooltip = () => { tooltip.style.visibility = 'hidden'; tooltip.style.opacity = '0'; }; rect.addEventListener('mouseenter', showTooltip); rect.addEventListener('mousemove', moveTooltip); rect.addEventListener('mouseleave', hideTooltip); // Keyboard accessibility rect.addEventListener('focus', showTooltip); rect.addEventListener('blur', hideTooltip); frag.appendChild(rect); }); }); svg.appendChild(frag); container.appendChild(svg); } }; window.ActivityHeatmap = ActivityHeatmap;