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
+3
View File
@@ -0,0 +1,3 @@
APP_ENV=prod
APP_DEBUG=0
APP_SECRET=changeme_replace_with_random_32char_string
+6
View File
@@ -0,0 +1,6 @@
/.env.local
/.env.*.local
/vendor/
/var/
/public/bundles/
composer.lock
+41
View File
@@ -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"]
+174
View File
@@ -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
```
![Example graph](https://img.shields.io/badge/output-SVG%20heatmap-39d353?style=flat-square)
---
## 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
![Contribution Graph](https://your-host/graph.svg?github_user=YOUR_USER&github_token=YOUR_TOKEN)
```
### GitHub + Gitea
```markdown
![Contribution Graph](https://your-host/graph.svg?github_user=YOUR_GH_USER&github_token=GH_TOKEN&gitea_user=YOUR_GITEA_USER&gitea_token=GITEA_TOKEN&gitea_url=https://git.arthurerlich.de)
```
### All three platforms
```markdown
![Contribution Graph](https://your-host/graph.svg?github_user=GH_USER&github_token=GH_TOKEN&gitlab_user=GL_USER&gitlab_token=GL_TOKEN&gitlab_url=https://gitlab.com&gitea_user=GITEA_USER&gitea_token=GITEA_TOKEN&gitea_url=https://git.arthurerlich.de)
```
### Dark vs Light theme
```markdown
<!-- Dark (default) -->
![Contribution Graph](https://your-host/graph.svg?github_user=USER&github_token=TOKEN&theme=dark)
<!-- Light -->
![Contribution Graph](https://your-host/graph.svg?github_user=USER&github_token=TOKEN&theme=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
View File
@@ -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);
};
+36
View File
@@ -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.*"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
<?php
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
];
+9
View File
@@ -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'
+5
View File
@@ -0,0 +1,5 @@
controllers:
resource:
path: ../src/Controller/
namespace: App\Controller
type: attribute
+23
View File
@@ -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:
+9
View File
@@ -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']);
};
+119
View File
@@ -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;
}
}
+11
View File
@@ -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;
}
+73
View File
@@ -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;
}
}
+62
View File
@@ -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;
}
}
+43
View File
@@ -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;
}
}
+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;
}
}