[ '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( '', $totalW, $totalH, $totalW, $totalH ); // Background $out .= sprintf('', $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( '%s contributions in the last year', $totalW - self::PADDING, $totalH - 2, $colors['text'], number_format($stats['total']) ); $out .= ''; 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; } }