Files
git-contribution-graph/src/Service/SvgRenderer.php
T
2026-05-30 14:13:51 +02:00

253 lines
8.5 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
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
*/
final 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 STEP = 13; // CELL + 3px 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('last 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($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']);
$suffix = $cell['count'] !== 1 ? 's' : '';
$label = $cell['count'] > 0
? $cell['count'] . ' contribution' . $suffix . ' 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 $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;
}
}