Files
Mati.ss/src/components/ActivityHeatmap.jsx

330 lines
13 KiB
JavaScript

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);
const [hoveredData, setHoveredData] = useState(null);
useEffect(() => {
const fetchData = async () => {
// Initialize default empty data
let githubData = { contributions: [] };
let giteaData = [];
try {
// 1. Fetch GitHub Data (Safe Fetch)
try {
const githubRes = await fetch('https://github-contributions-api.jogruber.de/v4/MatissJurevics');
if (githubRes.ok) {
githubData = await githubRes.json();
} else {
console.warn("GitHub API fetch failed:", githubRes.status);
}
} catch (e) {
console.warn("GitHub API error:", e);
}
// 2. Fetch Gitea Data (Safe Fetch)
try {
const giteaRes = await fetch('/api/gitea/api/v1/users/Matiss/heatmap');
if (giteaRes.ok) {
giteaData = await giteaRes.json();
} else {
console.warn("Gitea API fetch failed:", giteaRes.status);
}
} catch (e) {
console.warn("Gitea API error:", e);
}
// 3. Process & Merge
const processData = () => {
const merged = new Map();
const today = new Date();
const oneYearAgo = new Date();
oneYearAgo.setDate(today.getDate() - 365);
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
if (githubData && githubData.contributions) {
githubData.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
if (Array.isArray(giteaData)) {
giteaData.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("Critical error in ActivityHeatmap:", err);
// setError(err.message); // Suppress global error to show partial data
} 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(220, 100)`); // Moved up to prevent cutoff
// 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()}`, d);
}
// 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()}`, d);
}
});
// Function to draw isometric prism
function drawBar(container, x, y, w, h, z, color, tooltipText, dataItem) {
// 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 (event) {
d3.select(this).selectAll("path").attr("opacity", 0.8);
// Calculate position relative to container
const [mx, my] = d3.pointer(event, svg.node());
setHoveredData({
x: mx,
y: my,
date: d3.select(this).datum().date,
github: d3.select(this).datum().github,
gitea: d3.select(this).datum().gitea
});
}).on("mouseleave", function () {
d3.select(this).selectAll("path").attr("opacity", 1);
setHoveredData(null);
});
// Attach data to group for access in handler
group.datum(dataItem);
}
}, [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', flexDirection: 'column' }}>
<div>DATA FLUX ERROR</div>
<div style={{ fontSize: '0.8rem', marginTop: '10px', color: '#888' }}>{error}</div>
</div>
);
}
return (
<section style={{
width: '100%',
height: '100%',
color: '#e4e4e4',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '0',
overflow: 'visible', // Allow tooltip to render outside if needed
position: 'relative' // Anchor for absolute tooltip
}}>
<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}
viewBox="0 0 1000 600"
preserveAspectRatio="xMidYMid meet"
style={{ width: '100%', height: 'auto', overflow: 'visible' }}
/>
{hoveredData && (
<div style={{
position: 'absolute',
left: hoveredData.x + 10, // Closer offset
top: hoveredData.y - 30,
background: 'rgba(0,0,0,0.9)',
border: '1px solid #444',
borderRadius: '4px',
padding: '10px',
pointerEvents: 'none',
zIndex: 10,
fontSize: '0.8rem',
fontFamily: 'monospace',
boxShadow: '0 4px 10px rgba(0,0,0,0.5)'
}}>
<div style={{ fontWeight: 'bold', marginBottom: '5px', color: '#fff' }}>
{hoveredData.date ? hoveredData.date.toDateString() : 'Date'}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<div style={{ color: '#2da44e' }}>GitHub: {hoveredData.github || 0}</div>
<div style={{ color: '#ff4d00' }}>Gitea: {hoveredData.gitea || 0}</div>
</div>
</div>
)}
</section>
);
};
export default ActivityHeatmap;