Initialize git-contribution-graph project with Docker setup, environment configuration, and core functionality for merging contributions from GitHub, GitLab, and Gitea into an SVG heatmap.

This commit is contained in:
2026-05-28 19:35:44 +02:00
commit 342490035b
17 changed files with 881 additions and 0 deletions
+250
View File
@@ -0,0 +1,250 @@
<?php
namespace App\Service;
/**
* Renders a GitHub-style contribution heatmap as an inline SVG.
*
* Layout mirrors the official GitHub contribution graph:
* - 53 columns (weeks), 7 rows (SunSat)
* - 10×10 px cells with 3 px gap → 13 px step
* - Month labels above, weekday labels (Mon/Wed/Fri) on the left
* - 5 intensity levels (04) matched to GitHub's colour palette
*/
class SvgRenderer
{
// GitHub's exact colour tokens
private const THEMES = [
'dark' => [
'bg' => '#0d1117',
'text' => '#8b949e',
'levels' => ['#161b22', '#0e4429', '#006d32', '#26a641', '#39d353'],
],
'light' => [
'bg' => '#ffffff',
'text' => '#57606a',
'levels' => ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39'],
],
];
private const CELL = 10; // px
private const GAP = 3; // px
private const STEP = 13; // CELL + GAP
private const WEEKS = 53;
private const MARGIN_X = 28; // left margin for day labels
private const MARGIN_Y = 20; // top margin for month labels
private const PADDING = 10; // outer padding
public function render(array $contributions, string $theme = 'dark'): string
{
$colors = self::THEMES[$theme] ?? self::THEMES['dark'];
$today = new \DateTimeImmutable('today');
// align grid: last column always ends on the Saturday of the current week
$endSat = $today->modify('Saturday this week');
$start = $endSat->modify('-' . (self::WEEKS - 1) . ' weeks')->modify('Sunday');
$grid = $this->buildGrid($start, $today, $contributions);
$stats = $this->computeStats($contributions);
$totalW = self::MARGIN_X + self::WEEKS * self::STEP + self::PADDING;
$totalH = self::MARGIN_Y + 7 * self::STEP + self::PADDING + 24; // +24 for legend row
$out = '';
$out .= sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="Contribution graph">',
$totalW, $totalH, $totalW, $totalH
);
// Background
$out .= sprintf('<rect width="%d" height="%d" rx="6" fill="%s"/>', $totalW, $totalH, $colors['bg']);
// Month labels
$out .= $this->renderMonthLabels($grid, $colors['text']);
// Day-of-week labels
$out .= $this->renderDayLabels($colors['text']);
// Cells
$out .= $this->renderCells($grid, $colors);
// Legend row (Less → More)
$out .= $this->renderLegend($totalW, $totalH, $colors);
// Total count
$out .= sprintf(
'<text x="%d" y="%d" fill="%s" font-size="9" font-family="-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif" text-anchor="end">%s contributions in the last year</text>',
$totalW - self::PADDING,
$totalH - 2,
$colors['text'],
number_format($stats['total'])
);
$out .= '</svg>';
return $out;
}
// -------------------------------------------------------------------------
/**
* Builds a [week][day] grid where each cell is ['date' => 'Y-m-d', 'count' => int]
* or null if the date is in the future.
*/
private function buildGrid(\DateTimeImmutable $start, \DateTimeImmutable $today, array $contributions): array
{
$grid = [];
$current = $start;
for ($w = 0; $w < self::WEEKS; $w++) {
$grid[$w] = [];
for ($d = 0; $d < 7; $d++) {
if ($current > $today) {
$grid[$w][$d] = null;
} else {
$date = $current->format('Y-m-d');
$grid[$w][$d] = ['date' => $date, 'count' => $contributions[$date] ?? 0];
}
$current = $current->modify('+1 day');
}
}
return $grid;
}
/** Map a contribution count to an intensity level 04. */
private function level(int $count): int
{
return match (true) {
$count === 0 => 0,
$count <= 3 => 1,
$count <= 6 => 2,
$count <= 9 => 3,
default => 4,
};
}
private function computeStats(array $contributions): array
{
return ['total' => array_sum($contributions)];
}
// -------------------------------------------------------------------------
private function renderMonthLabels(array $grid, string $textColor): string
{
$out = '';
$lastMonth = -1;
foreach ($grid as $w => $days) {
foreach ($days as $cell) {
if ($cell === null) {
continue;
}
$ts = strtotime($cell['date']);
$month = (int) date('n', $ts);
$dom = (int) date('j', $ts);
// Print label at the first cell of a new month (only if it fits — not in week 0 col)
if ($month !== $lastMonth && ($dom <= 7 || $w === 0)) {
$x = self::MARGIN_X + $w * self::STEP;
$out .= sprintf(
'<text x="%d" y="12" fill="%s" font-size="9" font-family="-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif">%s</text>',
$x,
$textColor,
date('M', $ts)
);
$lastMonth = $month;
break; // next week
}
}
}
return $out;
}
private function renderDayLabels(string $textColor): string
{
// Only Mon (index 1), Wed (3), Fri (5) — matching GitHub
$labels = [1 => 'Mon', 3 => 'Wed', 5 => 'Fri'];
$out = '';
foreach ($labels as $d => $label) {
$y = self::MARGIN_Y + $d * self::STEP + self::CELL;
$out .= sprintf(
'<text x="0" y="%d" fill="%s" font-size="9" font-family="-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif">%s</text>',
$y,
$textColor,
$label
);
}
return $out;
}
private function renderCells(array $grid, array $colors): string
{
$out = '';
foreach ($grid as $w => $days) {
foreach ($days as $d => $cell) {
if ($cell === null) {
continue;
}
$x = self::MARGIN_X + $w * self::STEP;
$y = self::MARGIN_Y + $d * self::STEP;
$color = $colors['levels'][$this->level($cell['count'])];
$ts = strtotime($cell['date']);
$label = $cell['count'] > 0
? $cell['count'] . ' contribution' . ($cell['count'] !== 1 ? 's' : '') . ' on ' . date('F j, Y', $ts)
: 'No contributions on ' . date('F j, Y', $ts);
$out .= sprintf(
'<rect x="%d" y="%d" width="%d" height="%d" rx="2" fill="%s" data-date="%s" data-count="%d"><title>%s</title></rect>',
$x, $y,
self::CELL, self::CELL,
$color,
$cell['date'],
$cell['count'],
htmlspecialchars($label, ENT_XML1)
);
}
}
return $out;
}
private function renderLegend(int $totalW, int $totalH, array $colors): string
{
$y = $totalH - 14;
$out = sprintf(
'<text x="%d" y="%d" fill="%s" font-size="9" font-family="-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif">Less</text>',
self::MARGIN_X,
$y + self::CELL,
$colors['text']
);
$startX = self::MARGIN_X + 26;
foreach ($colors['levels'] as $i => $color) {
$out .= sprintf(
'<rect x="%d" y="%d" width="%d" height="%d" rx="2" fill="%s"/>',
$startX + $i * (self::CELL + 2),
$y,
self::CELL, self::CELL,
$color
);
}
$moreX = $startX + count($colors['levels']) * (self::CELL + 2) + 4;
$out .= sprintf(
'<text x="%d" y="%d" fill="%s" font-size="9" font-family="-apple-system,BlinkMacSystemFont,\'Segoe UI\',Helvetica,Arial,sans-serif">More</text>',
$moreX,
$y + self::CELL,
$colors['text']
);
return $out;
}
}