feat: Add 3D activity heatmap component using D3 to visualize combined GitHub and Gitea contributions, accessible via a new tab.

This commit is contained in:
2025-12-15 20:33:51 +00:00
parent 06968a6820
commit 85d13b44c2
6 changed files with 734 additions and 3 deletions

View File

@@ -0,0 +1,276 @@
import React, { useEffect, useRef, useState } from 'react';
import * as d3 from 'd3';
const ActivityHeatmap = () => {
const svgRef = useRef(null);
const [loading, setLoading] = useState(true);
const [data, setData] = useState([]);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
// 1. Fetch GitHub Data (using proxy)
const githubRes = await fetch('https://github-contributions-api.jogruber.de/v4/MatissJurevics');
const githubJson = await githubRes.json();
// 2. Fetch Gitea Data
const giteaRes = await fetch('/api/gitea/api/v1/users/Matiss/heatmap');
const giteaJson = await giteaRes.json(); // Array of { timestamp, contributions }
// 3. Process & Merge
const processData = () => {
const merged = new Map();
// Initialize with GitHub data (usually last year)
// The proxy returns 'contributions' array for the last year usually?
// Actually correct structure from jogruber api is { total: {}, contributions: [ { date, count, level } ] }
// But let's check what it returns specifically or handle 'years' object.
// Usually structure val: { yearly: [] , total: {} }
// Let's rely on standard logic: get last 365 days.
const today = new Date();
const oneYearAgo = new Date();
oneYearAgo.setDate(today.getDate() - 365);
// Helper to normalize date string YYYY-MM-DD
const toKey = (date) => date.toISOString().split('T')[0];
// Initialize map with empty days
for (let d = new Date(oneYearAgo); d <= today; d.setDate(d.getDate() + 1)) {
merged.set(toKey(d), { date: new Date(d), github: 0, gitea: 0 });
}
// Fill GitHub
// The API usually returns 'contributions' list.
// If structure is complex, we might need adjustments, but let's assume flat list available or extractable.
// Actually, jogruber V4 returns: { total: {}, contributions: [ { date, count, level } ... ] }
if (githubJson.contributions) {
githubJson.contributions.forEach(day => {
if (merged.has(day.date)) {
const curr = merged.get(day.date);
curr.github = day.count;
merged.set(day.date, curr);
}
});
}
// Fill Gitea
// Gitea heatmap endpoint returns array of { timestamp: unix_timestamp, contributions: count }
if (Array.isArray(giteaJson)) {
giteaJson.forEach(item => {
const d = new Date(item.timestamp * 1000);
const key = toKey(d);
if (merged.has(key)) {
const curr = merged.get(key);
curr.gitea = item.contributions;
merged.set(key, curr);
}
});
}
return Array.from(merged.values());
};
const processed = processData();
setData(processed);
} catch (err) {
console.error("Error fetching activity:", err);
setError(err.message);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
useEffect(() => {
if (loading || !data.length || !svgRef.current) return;
// D3 Drawing Logic
const width = 1000;
const height = 600;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const g = svg.append("g")
.attr("transform", `translate(${width / 2}, ${height / 4})`); // Center top-ish
// Isometric projection
// x grid runs diagonally right-down, y grid runs diagonally left-down
const tileWidth = 12;
const tileHeight = 7; // flattened appearance
// We need to organize data into weeks (x) and days of week (y)
// Similar to GitHub's contribution graph but 3D
// y: 0 (Sunday) - 6 (Saturday)
// x: Week index 0 - 52
const mappedData = data.map((d, i) => {
const dayOfWeek = d.date.getDay(); // 0-6
// Determine week index relative to start
const weekIndex = Math.floor(i / 7);
return {
...d,
gridX: weekIndex,
gridY: dayOfWeek
};
});
// Projection functions
// Iso 30 deg: x' = (col - row) * w, y' = (col + row) * h/2
const project = (col, row) => {
return {
x: (col - row) * tileWidth,
y: (col + row) * tileHeight
};
};
// Color scales
const maxVal = d3.max(mappedData, d => d.github + d.gitea) || 5;
const heightScale = d3.scaleLinear().domain([0, maxVal]).range([0, 50]);
// Colors
const githubColor = "#2da44e"; // GitHub Green
const giteaColor = "#609926"; // Gitea Green (slightly different, maybe orangey for contrast?)
// Let's use user's theme color for Gitea to contrast? User said "git tea", maybe stick to green varieties or separate?
// User asked for "stacked", so distinct colors helpful.
// Let's use Theme Orange (#ff4d00) for Gitea to match site, and GitHub Green for GitHub.
const colorGithub = "#2da44e";
const colorGitea = "#ff4d00";
// Draw standard floor tiles first (for context)
// Only needed if we want a "grid" look. Let's skip empty tiles for performance/cleanliness or draw dark base.
// Sort by gridY then gridX to render back-to-front correctly for painter's algorithm
// Render order: smallest y+x (back) to largest y+x (front)
// Actually for isometric:
// We want to draw cols (weeks) from left to right?
// Let's sort by sum of coords for simple stacking.
mappedData.sort((a, b) => (a.gridX + a.gridY) - (b.gridX + b.gridY));
mappedData.forEach(d => {
if (d.github === 0 && d.gitea === 0) {
// Draw faint base tile
const pos = project(d.gridX, d.gridY);
// Draw a simple diamond path
const path = `M${pos.x} ${pos.y}
L${pos.x + tileWidth} ${pos.y + tileHeight}
L${pos.x} ${pos.y + 2 * tileHeight}
L${pos.x - tileWidth} ${pos.y + tileHeight} Z`;
g.append("path")
.attr("d", path)
.attr("fill", "#222") // Dark tile
.attr("stroke", "none");
return;
}
const pos = project(d.gridX, d.gridY);
const totalHeight = heightScale(d.github + d.gitea);
const giteaH = heightScale(d.gitea);
const githubH = heightScale(d.github);
// Draw Gitea Bar (Bottom)
if (d.gitea > 0) {
drawBar(g, pos.x, pos.y, tileWidth, tileHeight, giteaH, colorGitea, `Gitea: ${d.gitea} on ${d.date.toDateString()}`);
}
// Draw GitHub Bar (Top)
// Adjust y position up by gitea height
if (d.github > 0) {
drawBar(g, pos.x, pos.y - giteaH, tileWidth, tileHeight, githubH, colorGithub, `GitHub: ${d.github} on ${d.date.toDateString()}`);
}
});
// Function to draw isometric prism
function drawBar(container, x, y, w, h, z, color, tooltipText) {
// Top Face
const pathTop = `M${x} ${y - z}
L${x + w} ${y + h - z}
L${x} ${y + 2 * h - z}
L${x - w} ${y + h - z} Z`;
// Right Face
const pathRight = `M${x + w} ${y + h - z}
L${x + w} ${y + h}
L${x} ${y + 2 * h}
L${x} ${y + 2 * h - z} Z`;
// Left Face
const pathLeft = `M${x - w} ${y + h - z}
L${x - w} ${y + h}
L${x} ${y + 2 * h}
L${x} ${y + 2 * h - z} Z`;
const group = container.append("g");
// Shading
const c = d3.color(color);
const cRight = c.darker(0.7);
const cLeft = c.darker(0.4);
group.append("path").attr("d", pathRight).attr("fill", cRight);
group.append("path").attr("d", pathLeft).attr("fill", cLeft);
group.append("path").attr("d", pathTop).attr("fill", c);
// Simple tooltip title
group.append("title").text(tooltipText);
// Hover effect
// group.on("mouseenter", function() {
// d3.select(this).selectAll("path").attr("opacity", 0.8);
// }).on("mouseleave", function() {
// d3.select(this).selectAll("path").attr("opacity", 1);
// });
}
}, [data, loading]);
if (loading) {
return (
<div style={{ height: '600px', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0a0a0a', color: '#666' }}>
ANALYZING COMMITS...
</div>
);
}
if (error) {
return (
<div style={{ height: '600px', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#0a0a0a', color: '#ff4d00' }}>
DATA FLUX ERROR
</div>
);
}
return (
<section style={{
height: '600px',
background: '#0a0a0a',
color: '#e4e4e4',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 20px',
overflow: 'hidden'
}}>
<h2 className="uppercase" style={{ fontSize: '1.5rem', marginBottom: '10px', color: '#888' }}>
Contribution Topography
</h2>
<div style={{ display: 'flex', gap: '20px', fontSize: '0.8rem', marginBottom: '20px', fontFamily: 'monospace' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<span style={{ width: '10px', height: '10px', background: '#2da44e' }}></span> GitHub
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<span style={{ width: '10px', height: '10px', background: '#ff4d00' }}></span> Gitea
</div>
</div>
<svg ref={svgRef} width="1000" height="600" style={{ maxWidth: '100%', height: 'auto', overflow: 'visible' }} />
</section>
);
};
export default ActivityHeatmap;