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 (