add nice statistics
This commit is contained in:
219
app/javascript/activity_heatmap.js
Normal file
219
app/javascript/activity_heatmap.js
Normal file
@@ -0,0 +1,219 @@
|
||||
// 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 = `<strong>${count}</strong> play${count === 1 ? '' : 's'}<br><span style='color:#666;'>${weekday}, ${iso}</span>`;
|
||||
};
|
||||
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;
|
||||
Reference in New Issue
Block a user