[
'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(
'';
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 0–4. */
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(
'%s',
$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(
'%s',
$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(
'%s',
$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(
'Less',
self::MARGIN_X,
$y + self::CELL,
$colors['text']
);
$startX = self::MARGIN_X + 26;
foreach ($colors['levels'] as $i => $color) {
$out .= sprintf(
'',
$startX + $i * (self::CELL + 2),
$y,
self::CELL, self::CELL,
$color
);
}
$moreX = $startX + count($colors['levels']) * (self::CELL + 2) + 4;
$out .= sprintf(
'More',
$moreX,
$y + self::CELL,
$colors['text']
);
return $out;
}
}