Sync your own workout data from your "Strong" app
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

added another example

+688 -2
+2 -2
README.MD
··· 7 7 8 8 If using docker-compose, the service will run 18:00, 18:30, 19:00, 19:30, 20:00 and 20:30, but you can change this in the `Dockerfile`. 9 9 10 + ![heatmap](https://r0.fyi/strong?style=dark) 11 + 10 12 ## Why? 11 13 12 14 So you can do fun stuff with your workout data, like doing silly grafana dashboards. 13 15 For example, you can use the [Grafana Clickhouse plugin](https://grafana.com/grafana/plugins/grafana-clickhouse-datasource/) to visualize your workout data. 14 - 15 - ![heatmap](https://r0.fyi/strong?style=dark) 16 16 17 17 You can make heatmaps, like the one you have on github, or you can make a dashboard that shows your progress over time, or you can make a dashboard that shows your workout history. 18 18
+34
examples/grafana/services/php-heatmap/Dockerfile
··· 1 + # Dockerfile 2 + # 3 + # Build and run without Docker Compose. You need a ClickHouse server running and accessible from the container. 4 + # You should use the docker compose from https://github.com/tolik518/strong-api-workout-fetch additionally to 5 + # have a ClickHouse server running with the workout data. 6 + # 7 + # docker build -t heatmap . 8 + # docker run -d --name heatmap \ 9 + # --restart unless-stopped \ 10 + # --network service-network \ 11 + # -p 1337:80 \ 12 + # -e CH_HOST=clickhouse-server \ 13 + # -e CH_USER=tolik518 \ 14 + # -e CH_PASS=admin \ 15 + # -e CH_DB=workouts \ 16 + # -e DEBUG=1 \ 17 + # -v "$PWD/workout-heatmap.php":/var/www/html/index.php:ro \ 18 + # heatmap 19 + # 20 + # Open: http://localhost:1337/ 21 + # 22 + FROM php:8.2-apache 23 + 24 + # GD-Extension für PNG/Text 25 + RUN apt-get update \ 26 + && apt-get install -y libpng-dev libjpeg-dev libfreetype6-dev \ 27 + && docker-php-ext-configure gd --with-freetype --with-jpeg \ 28 + && docker-php-ext-install -j$(nproc) gd \ 29 + && rm -rf /var/lib/apt/lists/* 30 + 31 + COPY workout-heatmap.php /var/www/html/workout-heatmap.php 32 + 33 + RUN echo "ServerName localhost" >> /etc/apache2/apache2.conf \ 34 + && echo "DirectoryIndex workout-heatmap.php" >> /etc/apache2/conf-enabled/docker-php.conf
+48
examples/grafana/services/php-heatmap/docker-compose.yml
··· 1 + # Docker Compose configuration intended for dev environment. It sets up a ClickHouse server and a PHP application to visualize workout data. 2 + services: 3 + heatmap: 4 + build: . 5 + container_name: heatmap 6 + ports: 7 + - "1337:80" 8 + volumes: 9 + - ./workout-heatmap.php:/var/www/html/workout-heatmap.php 10 + environment: 11 + - CH_HOST=clickhouse-server 12 + - CH_PORT=8123 13 + - CH_DB=workouts 14 + - CH_USER=tolik518 15 + - CH_PASS=admin 16 + - CH_PROTOCOL=http 17 + networks: 18 + - service-network 19 + depends_on: 20 + clickhouse-server: 21 + condition: service_healthy 22 + 23 + clickhouse-server: 24 + image: clickhouse/clickhouse-server:latest 25 + container_name: clickhouse-server 26 + volumes: 27 + - ./clickhouse/data:/var/lib/clickhouse 28 + - ./clickhouse/init.sql:/docker-entrypoint-initdb.d/init.sql 29 + - /var/log/clickhouse-server:/var/log/clickhouse-server 30 + ports: 31 + - "8123:8123" 32 + - "9000:9000" 33 + environment: 34 + - CLICKHOUSE_DB=workouts 35 + - CLICKHOUSE_USER=tolik518 36 + - CLICKHOUSE_PASSWORD=admin 37 + healthcheck: 38 + test: ["CMD", "wget", "--spider", "-q", "http://localhost:8123/ping"] 39 + interval: 5s 40 + timeout: 3s 41 + retries: 10 42 + networks: 43 + service-network: 44 + aliases: 45 + - clickhouse-server 46 + 47 + networks: 48 + service-network:
+604
examples/grafana/services/php-heatmap/workout-heatmap.php
··· 1 + <?php 2 + /** 3 + * Workout Heatmap (PNG) 4 + * 5 + * Renders a GitHub-style contribution heatmap of training tonnage (weight × reps) 6 + * from a ClickHouse database. Output is a PNG image with transparent or solid background. 7 + * 8 + * ----------------------------------------------------------------------------- 9 + * QUERY PARAMS 10 + * ----------------------------------------------------------------------------- 11 + * 12 + * Style & Colors: 13 + * ?style=dark Theme. Default: dark 14 + * Options: dark, light, material_dark, halloween, 15 + * dracula, solarized, nord, monokai, 16 + * gruvbox, one_dark, catppuccin, tokyo_night 17 + * ?bg=<hex|transparent> Override canvas background color. 18 + * e.g. ?bg=0d1117 or ?bg=transparent 19 + * ?empty=<hex|transparent> Override empty-cell fill color. 20 + * e.g. ?empty=161b22 or ?empty=transparent 21 + * 22 + * Date Range (mutually exclusive with ?weeks): 23 + * ?weeks=53 Number of weeks to show (1–60). Default: 53 24 + * ?from=YYYY-MM-DD Start date (aligns to Monday of that week) 25 + * ?to=YYYY-MM-DD End date (aligns to Monday of that week). Default: today 26 + * 27 + * ----------------------------------------------------------------------------- 28 + * EXAMPLE URLs 29 + * ----------------------------------------------------------------------------- 30 + * http://127.0.0.1:1337/workout-heatmap.php 31 + * http://127.0.0.1:1337/workout-heatmap.php?style=catppuccin&weeks=26 32 + * http://127.0.0.1:1337/workout-heatmap.php?style=halloween&from=2026-01-01&to=2026-04-20 33 + * http://127.0.0.1:1337/workout-heatmap.php?style=dark&bg=transparent&empty=transparent 34 + * 35 + * ----------------------------------------------------------------------------- 36 + * CLICKHOUSE CONNECTION (environment variables) 37 + * ----------------------------------------------------------------------------- 38 + * CLICKHOUSE_URL Full URL incl. credentials, e.g. http://user:pass@host:8123/?database=mydb 39 + * If set, the individual vars below are ignored. 40 + * CH_HOST Hostname / IP (required if CLICKHOUSE_URL not set) 41 + * CH_PORT HTTP port. Default: 8123 42 + * CH_DB Database name. Default: default 43 + * CH_USER Username 44 + * CH_PASS Password. Default: (empty) 45 + * CH_PROTOCOL http or https. Default: http 46 + */ 47 + 48 + header('Content-Type: image/png'); 49 + header('Cache-Control: public, max-age=300'); 50 + header('Expires: '.gmdate('D, d M Y H:i:s', time() + 300).' GMT'); 51 + 52 + /* --------------------------- Utilities --------------------------- */ 53 + function error_image($msg, $w = 680, $h = 42) { 54 + $im = imagecreatetruecolor($w, $h); 55 + imagealphablending($im, true); imagesavealpha($im, true); 56 + $bg = imagecolorallocatealpha($im, 0, 0, 0, 127); imagefill($im, 0, 0, $bg); 57 + $red = imagecolorallocate($im, 255, 64, 64); 58 + imagestring($im, 3, 6, 14, 'Error: '.$msg, $red); 59 + imagepng($im); imagedestroy($im); exit; 60 + } 61 + function startOfWeekMonday(DateTimeImmutable $dt) { 62 + $dow = (int)$dt->format('N'); // 1..7 (Mon..Sun) 63 + $delta = $dow - 1; 64 + return $dt->setTime(0,0,0)->sub(new DateInterval('P'.$delta.'D')); 65 + } 66 + 67 + /** 68 + * Parse a ?bg= / ?empty= query param. 69 + * Returns null for 'transparent', a 6-char lowercase hex string for valid hex, 70 + * or false if the value is absent or invalid (caller should not override). 71 + */ 72 + function parse_hex_param($param) { 73 + if ($param === null) return false; 74 + if ($param === 'transparent') return null; 75 + if (preg_match('/^#?([0-9a-f]{6}|[0-9a-f]{3})$/', $param, $m)) { 76 + $hex = $m[1]; 77 + if (strlen($hex) === 3) { 78 + $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2]; 79 + } 80 + return $hex; 81 + } 82 + return false; 83 + } 84 + 85 + /* --------------------------- Theme --------------------------- */ 86 + class Theme { 87 + public $name; 88 + public $levelHex = array(); // 1..4 89 + public $borderHex; 90 + public $emptyHex; // fill for past days with no data 91 + public $labelHex; 92 + public $bgHex = null; // canvas background (null = transparent) 93 + 94 + // Allocated GD colors (filled later for a given image) 95 + public $level = array(); // 1..4 96 + public $border; 97 + public $empty; 98 + public $label; 99 + public $bg = null; 100 + 101 + public static function make($style) { 102 + $s = strtolower((string)$style); 103 + if ($s === 'light') return self::light(); 104 + if ($s === 'material_dark') return self::materialDark(); 105 + if ($s === 'halloween') return self::halloween(); 106 + if ($s === 'dracula') return self::dracula(); 107 + if ($s === 'solarized') return self::solarized(); 108 + if ($s === 'nord') return self::nord(); 109 + if ($s === 'monokai') return self::monokai(); 110 + if ($s === 'gruvbox') return self::gruvbox(); 111 + if ($s === 'one_dark') return self::oneDark(); 112 + if ($s === 'catppuccin') return self::catppuccin(); 113 + if ($s === 'tokyo_night') return self::tokyoNight(); 114 + return self::dark(); // default 115 + } 116 + 117 + public static function dark() { 118 + $t = new self(); 119 + $t->name = 'dark'; 120 + // GitHub Dark greens: 0e4429, 006d32, 26a641, 39d353 121 + $t->levelHex = array( 122 + 1 => '0e4429', 123 + 2 => '006d32', 124 + 3 => '26a641', 125 + 4 => '39d353', 126 + ); 127 + $t->borderHex = '30363d'; 128 + $t->emptyHex = '161b22'; 129 + $t->labelHex = '8b949e'; 130 + $t->bgHex = '0d1117'; 131 + return $t; 132 + } 133 + 134 + public static function light() { 135 + $t = new self(); 136 + $t->name = 'light'; 137 + // GitHub Light greens: 9be9a8, 40c463, 30a14e, 216e39 138 + $t->levelHex = array( 139 + 1 => '9be9a8', 140 + 2 => '40c463', 141 + 3 => '30a14e', 142 + 4 => '216e39', 143 + ); 144 + $t->borderHex = 'd0d7de'; 145 + $t->emptyHex = 'ebedf0'; 146 + $t->labelHex = '57606a'; 147 + $t->bgHex = 'ffffff'; 148 + return $t; 149 + } 150 + 151 + public static function materialDark() { 152 + $t = new self(); 153 + $t->name = 'material_dark'; 154 + // Material Green ramp 155 + $t->levelHex = array( 156 + 1 => '1b5e20', // Green 900 157 + 2 => '2e7d32', // Green 800/700 158 + 3 => '43a047', // Green 600 159 + 4 => '66bb6a', // Green 400 160 + ); 161 + $t->borderHex = '37474f'; // Blue Grey 800 162 + $t->emptyHex = '263238'; // Blue Grey 900 163 + $t->labelHex = 'b0bec5'; // Blue Grey 200 164 + $t->bgHex = '121212'; 165 + return $t; 166 + } 167 + 168 + public static function halloween() { 169 + $t = new self(); 170 + $t->name = 'halloween'; 171 + // Classic GitHub Halloween orange ramp 172 + $t->levelHex = array( 173 + 1 => '631c03', 174 + 2 => 'bd561d', 175 + 3 => 'fa7a18', 176 + 4 => 'fddf68', 177 + ); 178 + $t->borderHex = '1a1a1a'; 179 + $t->emptyHex = '1e1600'; 180 + $t->labelHex = '8b949e'; 181 + $t->bgHex = '0d1117'; 182 + return $t; 183 + } 184 + 185 + public static function dracula() { 186 + $t = new self(); 187 + $t->name = 'dracula'; 188 + // Dracula theme purples 189 + $t->levelHex = array( 190 + 1 => '44475a', 191 + 2 => '6272a4', 192 + 3 => 'bd93f9', 193 + 4 => 'ff79c6', 194 + ); 195 + $t->borderHex = '282a36'; 196 + $t->emptyHex = '383a4a'; 197 + $t->labelHex = '6272a4'; 198 + $t->bgHex = '282a36'; 199 + return $t; 200 + } 201 + 202 + public static function solarized() { 203 + $t = new self(); 204 + $t->name = 'solarized'; 205 + // Solarized Dark cyan/blue ramp 206 + $t->levelHex = array( 207 + 1 => '003f4d', 208 + 2 => '006f73', 209 + 3 => '2aa198', 210 + 4 => '93a1a1', 211 + ); 212 + $t->borderHex = '073642'; 213 + $t->emptyHex = '073642'; // base02 214 + $t->labelHex = '657b83'; 215 + $t->bgHex = '002b36'; // base03 216 + return $t; 217 + } 218 + 219 + public static function nord() { 220 + $t = new self(); 221 + $t->name = 'nord'; 222 + // Nord polar night -> frost 223 + $t->levelHex = array( 224 + 1 => '3b4252', 225 + 2 => '5e81ac', 226 + 3 => '81a1c1', 227 + 4 => '88c0d0', 228 + ); 229 + $t->borderHex = '2e3440'; 230 + $t->emptyHex = '3b4252'; // nord1 231 + $t->labelHex = '616e88'; 232 + $t->bgHex = '2e3440'; // nord0 233 + return $t; 234 + } 235 + 236 + public static function monokai() { 237 + $t = new self(); 238 + $t->name = 'monokai'; 239 + // Monokai yellow/green 240 + $t->levelHex = array( 241 + 1 => '3d3d00', 242 + 2 => '75715e', 243 + 3 => 'a6e22e', 244 + 4 => 'e6db74', 245 + ); 246 + $t->borderHex = '272822'; 247 + $t->emptyHex = '3e3d32'; 248 + $t->labelHex = '75715e'; 249 + $t->bgHex = '272822'; 250 + return $t; 251 + } 252 + 253 + public static function gruvbox() { 254 + $t = new self(); 255 + $t->name = 'gruvbox'; 256 + // Gruvbox Dark warm yellow/orange ramp 257 + $t->levelHex = array( 258 + 1 => '3c3836', // bg2 259 + 2 => 'd65d0e', // orange 260 + 3 => 'd79921', // yellow 261 + 4 => 'fabd2f', // bright yellow 262 + ); 263 + $t->borderHex = '282828'; // bg 264 + $t->emptyHex = '32302f'; // bg1 265 + $t->labelHex = 'a89984'; // fg4 266 + $t->bgHex = '282828'; 267 + return $t; 268 + } 269 + 270 + public static function oneDark() { 271 + $t = new self(); 272 + $t->name = 'one_dark'; 273 + // Atom One Dark green ramp 274 + $t->levelHex = array( 275 + 1 => '1e4a1e', 276 + 2 => '2d6a2d', 277 + 3 => '98c379', // One Dark green 278 + 4 => 'b5e890', 279 + ); 280 + $t->borderHex = '282c34'; // One Dark bg 281 + $t->emptyHex = '2c313c'; 282 + $t->labelHex = '5c6370'; // One Dark comment 283 + $t->bgHex = '282c34'; 284 + return $t; 285 + } 286 + 287 + public static function catppuccin() { 288 + $t = new self(); 289 + $t->name = 'catppuccin'; 290 + // Catppuccin Mocha green ramp 291 + $t->levelHex = array( 292 + 1 => '1e3a2f', 293 + 2 => '2d5a45', 294 + 3 => '40a02b', // Catppuccin green 295 + 4 => 'a6e3a1', // Catppuccin bright green 296 + ); 297 + $t->borderHex = '1e1e2e'; // Mocha base 298 + $t->emptyHex = '313244'; // Mocha surface0 299 + $t->labelHex = '6c7086'; // Mocha overlay1 300 + $t->bgHex = '1e1e2e'; 301 + return $t; 302 + } 303 + 304 + public static function tokyoNight() { 305 + $t = new self(); 306 + $t->name = 'tokyo_night'; 307 + // Tokyo Night blue/cyan ramp 308 + $t->levelHex = array( 309 + 1 => '1a2035', 310 + 2 => '1d3557', 311 + 3 => '2ac3de', // Tokyo Night cyan 312 + 4 => '7dcfff', // Tokyo Night bright cyan 313 + ); 314 + $t->borderHex = '1a1b26'; // Tokyo Night bg 315 + $t->emptyHex = '1f2335'; // Tokyo Night night 316 + $t->labelHex = '565f89'; // Tokyo Night comment 317 + $t->bgHex = '1a1b26'; 318 + return $t; 319 + } 320 + 321 + private static function alloc_hex($im, $hex, $alpha = 0) { 322 + $hex = ltrim($hex, '#'); 323 + $r = hexdec(substr($hex, 0, 2)); 324 + $g = hexdec(substr($hex, 2, 2)); 325 + $b = hexdec(substr($hex, 4, 2)); 326 + return imagecolorallocatealpha($im, $r, $g, $b, $alpha); 327 + } 328 + 329 + public function allocateFor($im) { 330 + foreach ($this->levelHex as $k => $hex) { 331 + $this->level[$k] = self::alloc_hex($im, $hex, 0); 332 + } 333 + $this->border = self::alloc_hex($im, $this->borderHex, 0); 334 + $this->empty = $this->emptyHex !== null ? self::alloc_hex($im, $this->emptyHex, 0) : null; 335 + $this->label = self::alloc_hex($im, $this->labelHex, 0); 336 + $this->bg = $this->bgHex !== null ? self::alloc_hex($im, $this->bgHex, 0) : null; 337 + } 338 + } 339 + 340 + /* --------------------------- Layout --------------------------- */ 341 + class Layout { 342 + public $cell = 11; // px 343 + public $gap = 2; // px 344 + public $rad = 1; // px corner radius 345 + 346 + public $leftPad = 42; 347 + public $topPad = 28; 348 + public $rightPad = 18; 349 + public $bottomPad = 28; 350 + } 351 + 352 + /* --------------------------- ClickHouse --------------------------- */ 353 + class ClickHouseClient { 354 + private $url; 355 + private $user; 356 + private $pass; 357 + 358 + public function __construct() { 359 + $envUrl = getenv('CLICKHOUSE_URL'); 360 + if ($envUrl) { 361 + $this->url = $envUrl; 362 + } else { 363 + $host = getenv('CH_HOST'); 364 + if (!$host) error_image('CH_HOST not set'); 365 + $port = getenv('CH_PORT') ?: '8123'; 366 + $db = getenv('CH_DB') ?: 'default'; 367 + $proto= getenv('CH_PROTOCOL') ?: 'http'; 368 + $this->url = $proto.'://'.$host.':'.$port.'/?database='.rawurlencode($db).'&default_format=JSONCompact'; 369 + } 370 + $this->user = getenv('CH_USER'); 371 + $this->pass = getenv('CH_PASS') ?: ''; 372 + } 373 + 374 + public function fetchPerDay($fromSql, $toSql, $tzName = 'Europe/Berlin') { 375 + $sql = " 376 + WITH '" . $tzName . "' AS tz 377 + SELECT 378 + toDate(toTimeZone(start_date, tz)) AS day, 379 + sumIf(weight*reps, weight>0) AS tonnage 380 + FROM workouts.workout_sets 381 + WHERE start_date >= parseDateTimeBestEffort('$fromSql') 382 + AND start_date < parseDateTimeBestEffort('$toSql') 383 + GROUP BY day 384 + ORDER BY day 385 + "; 386 + 387 + $ch = curl_init($this->url); 388 + if ($ch === false) error_image('curl_init failed'); 389 + curl_setopt($ch, CURLOPT_POST, true); 390 + curl_setopt($ch, CURLOPT_POSTFIELDS, $sql); 391 + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 392 + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3); 393 + curl_setopt($ch, CURLOPT_TIMEOUT, 8); 394 + 395 + if ($this->user !== false && $this->user !== null) { 396 + curl_setopt($ch, CURLOPT_USERPWD, $this->user.':'.$this->pass); 397 + } 398 + 399 + $resp = curl_exec($ch); 400 + $errno = curl_errno($ch); 401 + $err = curl_error($ch); 402 + $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); 403 + curl_close($ch); 404 + 405 + if ($errno) error_image($err); 406 + if ($code < 200 || $code >= 300) error_image("HTTP $code"); 407 + 408 + $json = json_decode($resp, true); 409 + if (!is_array($json) || !isset($json['data'])) error_image('decode failure'); 410 + 411 + $perDay = array(); 412 + foreach ($json['data'] as $row) { 413 + $perDay[$row[0]] = (float)$row[1]; 414 + } 415 + return $perDay; 416 + } 417 + } 418 + 419 + /* --------------------------- Renderer --------------------------- */ 420 + class HeatmapRenderer { 421 + private $theme; 422 + private $layout; 423 + private $tz; 424 + 425 + public function __construct(Theme $theme, Layout $layout, DateTimeZone $tz) { 426 + $this->theme = $theme; 427 + $this->layout = $layout; 428 + $this->tz = $tz; 429 + } 430 + 431 + private static function drawRoundedRect($im, $x, $y, $w, $h, $r, $fill, $border) { 432 + $r = max(0, min($r, (int)floor(min($w, $h) / 2))); 433 + 434 + // fill 435 + imagefilledrectangle($im, $x + $r, $y, $x + $w - 1 - $r, $y + $h - 1, $fill); 436 + imagefilledrectangle($im, $x, $y + $r, $x + $w - 1, $y + $h - 1 - $r, $fill); 437 + if ($r > 0) { 438 + imagefilledellipse($im, $x + $r, $y + $r, $r * 2, $r * 2, $fill); 439 + imagefilledellipse($im, $x + $w - 1 - $r, $y + $r, $r * 2, $r * 2, $fill); 440 + imagefilledellipse($im, $x + $w - 1 - $r, $y + $h - 1 - $r, $r * 2, $r * 2, $fill); 441 + imagefilledellipse($im, $x + $r, $y + $h - 1 - $r, $r * 2, $r * 2, $fill); 442 + } 443 + 444 + // border 445 + imagesetthickness($im, 1); 446 + if ($r > 0) { 447 + imagearc($im, $x + $r, $y + $r, $r * 2, $r * 2, 180, 270, $border); 448 + imagearc($im, $x + $w - 1 - $r, $y + $r, $r * 2, $r * 2, 270, 0, $border); 449 + imagearc($im, $x + $w - 1 - $r, $y + $h - 1 - $r, $r * 2, $r * 2, 0, 90, $border); 450 + imagearc($im, $x + $r, $y + $h - 1 - $r, $r * 2, $r * 2, 90, 180, $border); 451 + imageline($im, $x + $r, $y, $x + $w - 1 - $r, $y, $border); 452 + imageline($im, $x + $w - 1, $y + $r, $x + $w - 1, $y + $h - 1 - $r, $border); 453 + imageline($im, $x + $r, $y + $h - 1, $x + $w - 1 - $r, $y + $h - 1, $border); 454 + imageline($im, $x, $y + $r, $x, $y + $h - 1 - $r, $border); 455 + } else { 456 + imagerectangle($im, $x, $y, $x + $w - 1, $y + $h - 1, $border); 457 + } 458 + } 459 + 460 + public function render($perDay, DateTimeImmutable $w_from, DateTimeImmutable $w_to) { 461 + $weeksCount = (int)round(($w_to->getTimestamp() - $w_from->getTimestamp()) / (7*24*3600)) + 1; 462 + 463 + $cell = $this->layout->cell; $gap = $this->layout->gap; $rad = $this->layout->rad; 464 + $left = $this->layout->leftPad; $top = $this->layout->topPad; 465 + $right= $this->layout->rightPad; $bottom = $this->layout->bottomPad; 466 + 467 + $width = $left + $weeksCount * ($cell + $gap) + $right; 468 + $height = $top + 7 * ($cell + $gap) + $bottom; 469 + 470 + $im = imagecreatetruecolor($width, $height); 471 + imagesavealpha($im, true); 472 + imagealphablending($im, false); 473 + $transparent = imagecolorallocatealpha($im, 0, 0, 0, 127); 474 + imagefill($im, 0, 0, $transparent); 475 + imagealphablending($im, true); 476 + 477 + // allocate theme colors for this image 478 + $this->theme->allocateFor($im); 479 + 480 + // fill canvas with theme background 481 + if ($this->theme->bg !== null) { 482 + imagefilledrectangle($im, 0, 0, $width - 1, $height - 1, $this->theme->bg); 483 + } 484 + 485 + // robust max (95th percentile) 486 + $vals = array(); 487 + foreach ($perDay as $v) { if ($v > 0) $vals[] = $v; } 488 + sort($vals); 489 + $maxVal = 1.0; 490 + if (count($vals) > 0) { 491 + $idx = (int)floor(0.95 * (count($vals) - 1)); 492 + $maxVal = max(1.0, $vals[$idx]); 493 + } 494 + 495 + // month labels 496 + $monthNames = array('Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'); 497 + for ($wi = 0; $wi < $weeksCount; $wi++) { 498 + $weekStart = $w_from->add(new DateInterval('P'.($wi*7).'D')); 499 + $prevWeek = ($wi === 0) ? $weekStart : $w_from->add(new DateInterval('P'.(($wi-1)*7).'D')); 500 + if ($wi === 0 || $weekStart->format('m') !== $prevWeek->format('m')) { 501 + $mn = $monthNames[(int)$weekStart->format('n') - 1]; 502 + $x = $left + $wi * ($cell + $gap); 503 + imagestring($im, 3, $x, 14, $mn, $this->theme->label); 504 + } 505 + } 506 + 507 + // weekday labels 508 + $weekdayLabels = array(1 => 'Tue', 3 => 'Thu', 5 => 'Sat'); 509 + for ($dow = 0; $dow < 7; $dow++) { 510 + if (isset($weekdayLabels[$dow])) { 511 + $y = $top + $dow * ($cell + $gap) -1; 512 + imagestring($im, 2, 12, $y, $weekdayLabels[$dow], $this->theme->label); 513 + } 514 + } 515 + 516 + // grid 517 + $today = (new DateTimeImmutable('today', $this->tz))->format('Y-m-d'); 518 + for ($wi = 0; $wi < $weeksCount; $wi++) { 519 + $weekStart = $w_from->add(new DateInterval('P'.($wi*7).'D')); 520 + for ($dow = 0; $dow < 7; $dow++) { 521 + $day = $weekStart->add(new DateInterval('P'.$dow.'D'))->format('Y-m-d'); 522 + 523 + // skip future days 524 + if ($day > $today) continue; 525 + 526 + $val = isset($perDay[$day]) ? $perDay[$day] : 0.0; 527 + 528 + $level = 0; 529 + if ($val > 0) { 530 + $ratio = $val / $maxVal; // 0..>=1 531 + $level = max(1, min(4, (int)ceil($ratio * 4))); 532 + } 533 + 534 + $x = $left + $wi * ($cell + $gap); 535 + $y = $top + $dow * ($cell + $gap); 536 + 537 + $fill = ($level === 0) ? ($this->theme->empty ?? $transparent) : $this->theme->level[$level]; 538 + self::drawRoundedRect($im, $x, $y, $cell, $cell, $rad, $fill, $this->theme->border); 539 + } 540 + } 541 + 542 + // legend (bottom right) 543 + $legendY = $height - 18; 544 + $legendTextLess = 'Less'; $legendTextMore = 'More'; 545 + $lx = $width - 180; if ($lx < $left) $lx = $left; 546 + 547 + imagestring($im, 2, $lx, $legendY, $legendTextLess, $this->theme->label); 548 + $bx = $lx + 28; 549 + for ($i = 1; $i <= 4; $i++) { 550 + self::drawRoundedRect($im, $bx, $legendY-1, 10, 10, 2, $this->theme->level[$i], $this->theme->border); 551 + $bx += 13; 552 + } 553 + imagestring($im, 2, $bx + 4, $legendY, $legendTextMore, $this->theme->label); 554 + 555 + imagepng($im); 556 + imagedestroy($im); 557 + } 558 + } 559 + 560 + /* --------------------------- App --------------------------- */ 561 + 562 + // Inputs 563 + $tz = new DateTimeZone('Europe/Berlin'); 564 + $styleName = isset($_GET['style']) ? (string)$_GET['style'] : 'dark'; 565 + $weeks = isset($_GET['weeks']) ? max(1, min(60, (int)$_GET['weeks'])) : 53; 566 + $toParam = isset($_GET['to']) ? $_GET['to'] : null; 567 + $fromParam = isset($_GET['from']) ? $_GET['from'] : null; 568 + $bgParam = isset($_GET['bg']) ? strtolower(trim($_GET['bg'])) : null; 569 + $emptyParam = isset($_GET['empty']) ? strtolower(trim($_GET['empty'])) : null; 570 + 571 + try { 572 + $toDate = $toParam ? new DateTimeImmutable($toParam, $tz) : new DateTimeImmutable('today', $tz); 573 + } catch (Exception $e) { 574 + error_image('Invalid "to" date: '.$toParam); 575 + } 576 + try { 577 + $fromDate = $fromParam ? new DateTimeImmutable($fromParam, $tz) : $toDate->sub(new DateInterval('P'.($weeks*7-1).'D')); 578 + } catch (Exception $e) { 579 + error_image('Invalid "from" date: '.$fromParam); 580 + } 581 + 582 + // Align to whole weeks (Mon..Sun) 583 + $w_from = startOfWeekMonday($fromDate); 584 + $w_to = startOfWeekMonday($toDate); 585 + 586 + // Fetch data 587 + $fromSql = $w_from->format('Y-m-d 00:00:00'); 588 + $toSql = $w_to->add(new DateInterval('P7D'))->format('Y-m-d 00:00:00'); 589 + 590 + $client = new ClickHouseClient(); 591 + $perDay = $client->fetchPerDay($fromSql, $toSql, $tz->getName()); 592 + 593 + // Render 594 + $theme = Theme::make($styleName); 595 + // Apply ?bg= and ?empty= overrides 596 + $bg = parse_hex_param($bgParam); 597 + if ($bg !== false) $theme->bgHex = $bg; 598 + 599 + $empty = parse_hex_param($emptyParam); 600 + if ($empty !== false) $theme->emptyHex = $empty; 601 + 602 + $layout = new Layout(); 603 + $renderer= new HeatmapRenderer($theme, $layout, $tz); 604 + $renderer->render($perDay, $w_from, $w_to);