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:
276
src/components/ActivityHeatmap.jsx
Normal file
276
src/components/ActivityHeatmap.jsx
Normal 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;
|
||||
Reference in New Issue
Block a user