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:
@@ -0,0 +1,3 @@
|
||||
APP_ENV=prod
|
||||
APP_DEBUG=0
|
||||
APP_SECRET=changeme_replace_with_random_32char_string
|
||||
@@ -0,0 +1,6 @@
|
||||
/.env.local
|
||||
/.env.*.local
|
||||
/vendor/
|
||||
/var/
|
||||
/public/bundles/
|
||||
composer.lock
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
FROM php:8.3-cli-alpine AS base
|
||||
|
||||
# Runtime dependencies
|
||||
RUN apk add --no-cache \
|
||||
curl \
|
||||
icu-libs \
|
||||
libzip \
|
||||
&& docker-php-ext-install opcache
|
||||
|
||||
# Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# ── deps stage ────────────────────────────────────────────────────────────────
|
||||
FROM base AS deps
|
||||
COPY composer.json composer.lock* ./
|
||||
RUN composer install \
|
||||
--no-dev \
|
||||
--no-scripts \
|
||||
--no-interaction \
|
||||
--optimize-autoloader \
|
||||
--prefer-dist
|
||||
|
||||
# ── final stage ───────────────────────────────────────────────────────────────
|
||||
FROM base AS final
|
||||
|
||||
COPY --from=deps /app/vendor /app/vendor
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p var/cache var/log \
|
||||
&& chmod -R 777 var \
|
||||
&& composer dump-autoload --optimize --no-dev
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENV APP_ENV=prod \
|
||||
APP_DEBUG=0
|
||||
|
||||
# Symfony's built-in dev server via PHP — fine for a single-purpose container
|
||||
CMD ["php", "-S", "0.0.0.0:8080", "-t", "public", "public/index.php"]
|
||||
@@ -0,0 +1,174 @@
|
||||
# git-contribution-graph
|
||||
|
||||
A self-hosted Symfony service that merges contribution data from **GitHub**, **GitLab** and **Gitea** into a single GitHub-style heatmap SVG you can embed anywhere.
|
||||
|
||||
```
|
||||
https://your-host/graph.svg?github_user=you&github_token=ghp_…&gitea_user=you&gitea_token=…&gitea_url=https://git.example.com
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
- **Three platforms** — GitHub, GitLab (cloud or self-hosted), Gitea (self-hosted)
|
||||
- **Merged heatmap** — contributions from all sources are summed per day
|
||||
- **GitHub colour palette** — exact dark/light theme tokens
|
||||
- **Embeddable** — returns `image/svg+xml`, works in any `<img>` tag or Markdown
|
||||
- **Cached** — responses cached for 1 hour, safe to embed in public READMEs
|
||||
- **Graceful degradation** — if one platform fails, the others still render
|
||||
|
||||
---
|
||||
|
||||
## Deploy
|
||||
|
||||
### Requirements
|
||||
|
||||
- Docker + Docker Compose
|
||||
|
||||
### 1. Clone and configure
|
||||
|
||||
```bash
|
||||
git clone https://git.arthurerlich.de/haylan/git-contribution-graph.git
|
||||
cd git-contribution-graph
|
||||
cp .env .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local`:
|
||||
|
||||
```dotenv
|
||||
APP_ENV=prod
|
||||
APP_DEBUG=0
|
||||
APP_SECRET=<generate with: openssl rand -hex 16>
|
||||
```
|
||||
|
||||
### 2. Start
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The service listens on **port 8080** by default. Put Traefik or nginx in front of it for HTTPS.
|
||||
|
||||
### 3. Health check
|
||||
|
||||
```
|
||||
GET /health → {"status":"ok"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `GET /graph.svg`
|
||||
|
||||
| Parameter | Required | Description |
|
||||
|---|---|---|
|
||||
| `github_user` | ✗ | GitHub username |
|
||||
| `github_token` | ✗ | GitHub PAT — scope: `read:user` |
|
||||
| `gitlab_user` | ✗ | GitLab username |
|
||||
| `gitlab_token` | ✗ | GitLab PAT — scope: `read_user`, `read_api` |
|
||||
| `gitlab_url` | ✗ | GitLab base URL (default: `https://gitlab.com`) |
|
||||
| `gitea_user` | ✗ | Gitea username |
|
||||
| `gitea_token` | ✗ | Gitea PAT — scope: `read:user` |
|
||||
| `gitea_url` | ✗ | Gitea instance URL, e.g. `https://git.example.com` |
|
||||
| `theme` | ✗ | `dark` (default) or `light` |
|
||||
|
||||
All platform parameters are optional — include only the ones you use. At least one platform must be configured for a non-empty graph.
|
||||
|
||||
---
|
||||
|
||||
## Embedding in a README
|
||||
|
||||
### GitHub-only
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
### GitHub + Gitea
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
### All three platforms
|
||||
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
### Dark vs Light theme
|
||||
|
||||
```markdown
|
||||
<!-- Dark (default) -->
|
||||

|
||||
|
||||
<!-- Light -->
|
||||

|
||||
```
|
||||
|
||||
> **Security note:** API tokens embedded in public Markdown URLs are visible to anyone. Use read-only fine-grained tokens with minimal scopes. For private repos or sensitive setups, proxy the request server-side and keep tokens in environment variables.
|
||||
|
||||
---
|
||||
|
||||
## Token setup
|
||||
|
||||
### GitHub
|
||||
|
||||
1. Go to **Settings → Developer settings → Personal access tokens → Fine-grained tokens**
|
||||
2. Set **Resource owner** to your account
|
||||
3. Under **Permissions → Account permissions**, set **Contribution activity** → Read-only
|
||||
4. Generate and copy the token
|
||||
|
||||
### GitLab
|
||||
|
||||
1. Go to **User Settings → Access Tokens**
|
||||
2. Add a token with scopes: `read_user`, `read_api`
|
||||
3. Generate and copy the token
|
||||
|
||||
### Gitea
|
||||
|
||||
1. Go to **Settings → Applications → Manage Access Tokens**
|
||||
2. Add a token with permission: **user** → Read
|
||||
3. Generate and copy the token
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install deps
|
||||
composer install
|
||||
|
||||
# Run dev server
|
||||
APP_ENV=dev php -S localhost:8080 -t public
|
||||
|
||||
# Test endpoint
|
||||
curl "http://localhost:8080/graph.svg?gitea_user=haylan&gitea_token=YOUR_TOKEN&gitea_url=https://git.arthurerlich.de" -o graph.svg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
GET /graph.svg
|
||||
│
|
||||
├─ GitHubProvider → GitHub GraphQL API (contributionCalendar)
|
||||
├─ GitLabProvider → GitLab REST API (/users/:id/events)
|
||||
└─ GiteaProvider → Gitea REST API (/users/:user/heatmap)
|
||||
│
|
||||
merge by date (sum counts)
|
||||
│
|
||||
SvgRenderer
|
||||
│
|
||||
image/svg+xml (cached 1h)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
return new Application($kernel);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "haylan/git-contribution-graph",
|
||||
"description": "Unified contribution graph SVG for GitHub, GitLab and Gitea",
|
||||
"type": "project",
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": ">=8.2",
|
||||
"symfony/cache": "^7.1",
|
||||
"symfony/framework-bundle": "^7.1",
|
||||
"symfony/http-client": "^7.1",
|
||||
"symfony/runtime": "^7.1"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"App\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"symfony/runtime": true
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "7.1.*"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
http_method_override: false
|
||||
handle_all_throwables: true
|
||||
php_errors:
|
||||
log: true
|
||||
cache:
|
||||
app: cache.adapter.filesystem
|
||||
default_redis_provider: 'redis://localhost'
|
||||
@@ -0,0 +1,5 @@
|
||||
controllers:
|
||||
resource:
|
||||
path: ../src/Controller/
|
||||
namespace: App\Controller
|
||||
type: attribute
|
||||
@@ -0,0 +1,23 @@
|
||||
services:
|
||||
graph:
|
||||
build: .
|
||||
container_name: git-contribution-graph
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
APP_ENV: prod
|
||||
APP_DEBUG: "0"
|
||||
APP_SECRET: "${APP_SECRET}"
|
||||
volumes:
|
||||
- cache:/app/var/cache
|
||||
- logs:/app/var/log
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
cache:
|
||||
logs:
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Service\GiteaProvider;
|
||||
use App\Service\GitHubProvider;
|
||||
use App\Service\GitLabProvider;
|
||||
use App\Service\SvgRenderer;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
class GraphController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GitHubProvider $github,
|
||||
private readonly GitLabProvider $gitlab,
|
||||
private readonly GiteaProvider $gitea,
|
||||
private readonly SvgRenderer $renderer,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns a GitHub-style contribution graph SVG merging GitHub, GitLab and Gitea.
|
||||
*
|
||||
* Query parameters:
|
||||
* github_user string GitHub username
|
||||
* github_token string GitHub personal access token (needs read:user scope)
|
||||
* gitlab_user string GitLab username
|
||||
* gitlab_token string GitLab personal access token (needs read_user scope)
|
||||
* gitlab_url string GitLab base URL (default: https://gitlab.com)
|
||||
* gitea_user string Gitea username
|
||||
* gitea_token string Gitea personal access token
|
||||
* gitea_url string Gitea instance base URL (e.g. https://git.example.com)
|
||||
* theme string dark|light (default: dark)
|
||||
*/
|
||||
#[Route('/graph.svg', name: 'contribution_graph', methods: ['GET'])]
|
||||
public function graph(Request $request): Response
|
||||
{
|
||||
$params = $request->query->all();
|
||||
$theme = $params['theme'] ?? 'dark';
|
||||
|
||||
// Use a cache key based on the query params (minus sensitive tokens — we hash them)
|
||||
$cacheKey = 'graph_' . md5(serialize($params));
|
||||
|
||||
$svg = $this->cache->get($cacheKey, function (ItemInterface $item) use ($params, $theme): string {
|
||||
$item->expiresAfter(3600); // cache for 1 hour
|
||||
|
||||
$contributions = [];
|
||||
|
||||
if (!empty($params['github_user']) && !empty($params['github_token'])) {
|
||||
try {
|
||||
$contributions = $this->merge(
|
||||
$contributions,
|
||||
$this->github->fetch($params['github_user'], $params['github_token'])
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('GitHub fetch failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($params['gitlab_user']) && !empty($params['gitlab_token'])) {
|
||||
try {
|
||||
$contributions = $this->merge(
|
||||
$contributions,
|
||||
$this->gitlab->fetch(
|
||||
$params['gitlab_user'],
|
||||
$params['gitlab_token'],
|
||||
$params['gitlab_url'] ?? 'https://gitlab.com'
|
||||
)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('GitLab fetch failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($params['gitea_user']) && !empty($params['gitea_token']) && !empty($params['gitea_url'])) {
|
||||
try {
|
||||
$contributions = $this->merge(
|
||||
$contributions,
|
||||
$this->gitea->fetch(
|
||||
$params['gitea_user'],
|
||||
$params['gitea_token'],
|
||||
rtrim($params['gitea_url'], '/')
|
||||
)
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
$this->logger->warning('Gitea fetch failed: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $this->renderer->render($contributions, $theme);
|
||||
});
|
||||
|
||||
return new Response($svg, 200, [
|
||||
'Content-Type' => 'image/svg+xml',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
}
|
||||
|
||||
#[Route('/health', name: 'health', methods: ['GET'])]
|
||||
public function health(): Response
|
||||
{
|
||||
return new Response('{"status":"ok"}', 200, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
|
||||
/** Sum contributions by date from two maps. */
|
||||
private function merge(array $base, array $new): array
|
||||
{
|
||||
foreach ($new as $date => $count) {
|
||||
$base[$date] = ($base[$date] ?? 0) + $count;
|
||||
}
|
||||
return $base;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Fetches the last 365 days of contributions from the GitHub GraphQL API.
|
||||
*
|
||||
* Required token scopes: read:user
|
||||
*/
|
||||
class GitHubProvider
|
||||
{
|
||||
private const GRAPHQL_URL = 'https://api.github.com/graphql';
|
||||
|
||||
private const QUERY = <<<'GRAPHQL'
|
||||
query($username: String!, $from: DateTime!, $to: DateTime!) {
|
||||
user(login: $username) {
|
||||
contributionsCollection(from: $from, to: $to) {
|
||||
contributionCalendar {
|
||||
weeks {
|
||||
contributionDays {
|
||||
date
|
||||
contributionCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
GRAPHQL;
|
||||
|
||||
public function __construct(private readonly HttpClientInterface $client) {}
|
||||
|
||||
/**
|
||||
* @return array<string, int> date (Y-m-d) => contribution count
|
||||
*/
|
||||
public function fetch(string $username, string $token): array
|
||||
{
|
||||
$from = (new \DateTimeImmutable('-365 days'))->format('Y-m-d\T00:00:00\Z');
|
||||
$to = (new \DateTimeImmutable())->format('Y-m-d\T23:59:59\Z');
|
||||
|
||||
$response = $this->client->request('POST', self::GRAPHQL_URL, [
|
||||
'headers' => [
|
||||
'Authorization' => "Bearer $token",
|
||||
'Content-Type' => 'application/json',
|
||||
],
|
||||
'json' => [
|
||||
'query' => self::QUERY,
|
||||
'variables' => ['username' => $username, 'from' => $from, 'to' => $to],
|
||||
],
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
|
||||
if (isset($data['errors'])) {
|
||||
throw new \RuntimeException('GitHub GraphQL error: ' . json_encode($data['errors']));
|
||||
}
|
||||
|
||||
$result = [];
|
||||
$weeks = $data['data']['user']['contributionsCollection']['contributionCalendar']['weeks'] ?? [];
|
||||
|
||||
foreach ($weeks as $week) {
|
||||
foreach ($week['contributionDays'] as $day) {
|
||||
if ($day['contributionCount'] > 0) {
|
||||
$result[$day['date']] = $day['contributionCount'];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Fetches the last 365 days of push/merge events from the GitLab REST API.
|
||||
*
|
||||
* Required token scopes: read_user, read_api
|
||||
* Works with both gitlab.com and self-hosted instances.
|
||||
*/
|
||||
class GitLabProvider
|
||||
{
|
||||
public function __construct(private readonly HttpClientInterface $client) {}
|
||||
|
||||
/**
|
||||
* @return array<string, int> date (Y-m-d) => event count
|
||||
*/
|
||||
public function fetch(string $username, string $token, string $baseUrl = 'https://gitlab.com'): array
|
||||
{
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
|
||||
// Resolve numeric user ID from username
|
||||
$userResponse = $this->client->request('GET', "$baseUrl/api/v4/users", [
|
||||
'headers' => ['PRIVATE-TOKEN' => $token],
|
||||
'query' => ['username' => $username],
|
||||
]);
|
||||
|
||||
$users = $userResponse->toArray();
|
||||
if (empty($users)) {
|
||||
throw new \RuntimeException("GitLab: user '$username' not found on $baseUrl");
|
||||
}
|
||||
$userId = $users[0]['id'];
|
||||
|
||||
$result = [];
|
||||
$after = (new \DateTimeImmutable('-365 days'))->format('Y-m-d');
|
||||
$page = 1;
|
||||
|
||||
do {
|
||||
$response = $this->client->request('GET', "$baseUrl/api/v4/users/$userId/events", [
|
||||
'headers' => ['PRIVATE-TOKEN' => $token],
|
||||
'query' => [
|
||||
'after' => $after,
|
||||
'per_page' => 100,
|
||||
'page' => $page,
|
||||
],
|
||||
]);
|
||||
|
||||
$events = $response->toArray();
|
||||
|
||||
foreach ($events as $event) {
|
||||
$date = substr($event['created_at'], 0, 10);
|
||||
$result[$date] = ($result[$date] ?? 0) + 1;
|
||||
}
|
||||
|
||||
$page++;
|
||||
} while (count($events) === 100);
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* Fetches contribution data from the Gitea heatmap endpoint.
|
||||
*
|
||||
* Endpoint: GET /api/v1/users/{username}/heatmap
|
||||
* Returns: [{"timestamp": 1234567890, "contributions": 3}, ...]
|
||||
*
|
||||
* Required token scopes: read:user
|
||||
*/
|
||||
class GiteaProvider
|
||||
{
|
||||
public function __construct(private readonly HttpClientInterface $client) {}
|
||||
|
||||
/**
|
||||
* @return array<string, int> date (Y-m-d) => contribution count
|
||||
*/
|
||||
public function fetch(string $username, string $token, string $baseUrl): array
|
||||
{
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
$response = $this->client->request('GET', "$baseUrl/api/v1/users/$username/heatmap", [
|
||||
'headers' => ['Authorization' => "token $token"],
|
||||
]);
|
||||
|
||||
$data = $response->toArray();
|
||||
$cutoff = (new \DateTimeImmutable('-365 days'))->getTimestamp();
|
||||
$result = [];
|
||||
|
||||
foreach ($data as $entry) {
|
||||
if ($entry['timestamp'] < $cutoff) {
|
||||
continue;
|
||||
}
|
||||
$date = date('Y-m-d', $entry['timestamp']);
|
||||
$result[$date] = ($result[$date] ?? 0) + (int) $entry['contributions'];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -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 (Sun–Sat)
|
||||
* - 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 (0–4) 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 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(
|
||||
'<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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user