fisharebest /
webtrees
| 1 | <?php |
||
| 2 | |||
| 3 | /** |
||
| 4 | * webtrees: online genealogy |
||
| 5 | * Copyright (C) 2025 webtrees development team |
||
| 6 | * This program is free software: you can redistribute it and/or modify |
||
| 7 | * it under the terms of the GNU General Public License as published by |
||
| 8 | * the Free Software Foundation, either version 3 of the License, or |
||
| 9 | * (at your option) any later version. |
||
| 10 | * This program is distributed in the hope that it will be useful, |
||
| 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
||
| 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
||
| 13 | * GNU General Public License for more details. |
||
| 14 | * You should have received a copy of the GNU General Public License |
||
| 15 | * along with this program. If not, see <https://www.gnu.org/licenses/>. |
||
| 16 | */ |
||
| 17 | |||
| 18 | declare(strict_types=1); |
||
| 19 | |||
| 20 | namespace Fisharebest\Webtrees\Module; |
||
| 21 | |||
| 22 | use Fig\Http\Message\RequestMethodInterface; |
||
| 23 | use Fisharebest\Webtrees\Auth; |
||
| 24 | use Fisharebest\Webtrees\Http\Middleware\AuthNotRobot; |
||
| 25 | use Fisharebest\Webtrees\I18N; |
||
| 26 | use Fisharebest\Webtrees\Individual; |
||
| 27 | use Fisharebest\Webtrees\Menu; |
||
| 28 | use Fisharebest\Webtrees\Registry; |
||
| 29 | use Fisharebest\Webtrees\Services\ChartService; |
||
| 30 | use Fisharebest\Webtrees\Validator; |
||
| 31 | use Fisharebest\Webtrees\Webtrees; |
||
| 32 | use GdImage; |
||
| 33 | use Psr\Http\Message\ResponseInterface; |
||
| 34 | use Psr\Http\Message\ServerRequestInterface; |
||
| 35 | use Psr\Http\Server\RequestHandlerInterface; |
||
| 36 | |||
| 37 | use function array_filter; |
||
| 38 | use function array_map; |
||
| 39 | use function cos; |
||
| 40 | use function deg2rad; |
||
| 41 | use function e; |
||
| 42 | use function gd_info; |
||
| 43 | use function hexdec; |
||
| 44 | use function imagecolorallocate; |
||
| 45 | use function imagecolortransparent; |
||
| 46 | use function imagecreate; |
||
| 47 | use function imagefilledarc; |
||
| 48 | use function imagefilledrectangle; |
||
| 49 | use function imagepng; |
||
| 50 | use function imagettfbbox; |
||
| 51 | use function imagettftext; |
||
| 52 | use function implode; |
||
| 53 | use function intdiv; |
||
| 54 | use function mb_substr; |
||
| 55 | use function ob_get_clean; |
||
| 56 | use function ob_start; |
||
| 57 | use function redirect; |
||
| 58 | use function response; |
||
| 59 | use function round; |
||
| 60 | use function route; |
||
| 61 | use function rtrim; |
||
| 62 | use function sin; |
||
| 63 | use function sqrt; |
||
| 64 | use function strip_tags; |
||
| 65 | use function substr; |
||
| 66 | use function view; |
||
| 67 | |||
| 68 | use const IMG_ARC_PIE; |
||
| 69 | |||
| 70 | class FanChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface |
||
| 71 | { |
||
| 72 | use ModuleChartTrait; |
||
| 73 | |||
| 74 | protected const string ROUTE_URL = '/tree/{tree}/fan-chart-{style}-{generations}-{width}/{xref}'; |
||
|
0 ignored issues
–
show
Bug
introduced
by
Loading history...
|
|||
| 75 | |||
| 76 | // Chart styles |
||
| 77 | private const int STYLE_HALF_CIRCLE = 2; |
||
| 78 | private const int STYLE_THREE_QUARTER_CIRCLE = 3; |
||
| 79 | private const int STYLE_FULL_CIRCLE = 4; |
||
| 80 | |||
| 81 | // Defaults |
||
| 82 | public const int DEFAULT_STYLE = self::STYLE_THREE_QUARTER_CIRCLE; |
||
| 83 | public const int DEFAULT_GENERATIONS = 4; |
||
| 84 | public const int DEFAULT_WIDTH = 100; |
||
| 85 | protected const array DEFAULT_PARAMETERS = [ |
||
| 86 | 'style' => self::DEFAULT_STYLE, |
||
| 87 | 'generations' => self::DEFAULT_GENERATIONS, |
||
| 88 | 'width' => self::DEFAULT_WIDTH, |
||
| 89 | ]; |
||
| 90 | |||
| 91 | // Limits |
||
| 92 | private const int MINIMUM_GENERATIONS = 2; |
||
| 93 | private const int MAXIMUM_GENERATIONS = 9; |
||
| 94 | private const int MINIMUM_WIDTH = 50; |
||
| 95 | private const int MAXIMUM_WIDTH = 500; |
||
| 96 | |||
| 97 | // Chart layout parameters |
||
| 98 | private const string FONT = Webtrees::ROOT_DIR . 'resources/fonts/DejaVuSans.ttf'; |
||
| 99 | private const int CHART_WIDTH_PIXELS = 800; |
||
| 100 | private const float TEXT_SIZE_POINTS = self::CHART_WIDTH_PIXELS / 120.0; |
||
| 101 | private const int GAP_BETWEEN_RINGS = 2; |
||
| 102 | |||
| 103 | private ChartService $chart_service; |
||
| 104 | |||
| 105 | /** |
||
| 106 | * @param ChartService $chart_service |
||
| 107 | */ |
||
| 108 | public function __construct(ChartService $chart_service) |
||
| 109 | { |
||
| 110 | $this->chart_service = $chart_service; |
||
| 111 | } |
||
| 112 | |||
| 113 | /** |
||
| 114 | * Initialization. |
||
| 115 | * |
||
| 116 | * @return void |
||
| 117 | */ |
||
| 118 | public function boot(): void |
||
| 119 | { |
||
| 120 | Registry::routeFactory()->routeMap() |
||
| 121 | ->get(static::class, static::ROUTE_URL, $this) |
||
| 122 | ->allows(RequestMethodInterface::METHOD_POST) |
||
| 123 | ->extras(['middleware' => [AuthNotRobot::class]]); |
||
| 124 | } |
||
| 125 | |||
| 126 | public function title(): string |
||
| 127 | { |
||
| 128 | /* I18N: Name of a module/chart */ |
||
| 129 | return I18N::translate('Fan chart'); |
||
| 130 | } |
||
| 131 | |||
| 132 | public function description(): string |
||
| 133 | { |
||
| 134 | /* I18N: Description of the “Fan Chart” module */ |
||
| 135 | return I18N::translate('A fan chart of an individual’s ancestors.'); |
||
| 136 | } |
||
| 137 | |||
| 138 | /** |
||
| 139 | * CSS class for the URL. |
||
| 140 | * |
||
| 141 | * @return string |
||
| 142 | */ |
||
| 143 | public function chartMenuClass(): string |
||
| 144 | { |
||
| 145 | return 'menu-chart-fanchart'; |
||
| 146 | } |
||
| 147 | |||
| 148 | /** |
||
| 149 | * Return a menu item for this chart - for use in individual boxes. |
||
| 150 | */ |
||
| 151 | public function chartBoxMenu(Individual $individual): Menu|null |
||
| 152 | { |
||
| 153 | return $this->chartMenu($individual); |
||
| 154 | } |
||
| 155 | |||
| 156 | /** |
||
| 157 | * The title for a specific instance of this chart. |
||
| 158 | * |
||
| 159 | * @param Individual $individual |
||
| 160 | * |
||
| 161 | * @return string |
||
| 162 | */ |
||
| 163 | public function chartTitle(Individual $individual): string |
||
| 164 | { |
||
| 165 | /* I18N: https://en.wikipedia.org/wiki/Family_tree#Fan_chart - %s is an individual’s name */ |
||
| 166 | return I18N::translate('Fan chart of %s', $individual->fullName()); |
||
| 167 | } |
||
| 168 | |||
| 169 | /** |
||
| 170 | * A form to request the chart parameters. |
||
| 171 | * |
||
| 172 | * @param Individual $individual |
||
| 173 | * @param array<bool|int|string|array<string>|null> $parameters |
||
| 174 | * |
||
| 175 | * @return string |
||
| 176 | */ |
||
| 177 | public function chartUrl(Individual $individual, array $parameters = []): string |
||
| 178 | { |
||
| 179 | return route(static::class, [ |
||
| 180 | 'xref' => $individual->xref(), |
||
| 181 | 'tree' => $individual->tree()->name(), |
||
| 182 | ] + $parameters + self::DEFAULT_PARAMETERS); |
||
| 183 | } |
||
| 184 | |||
| 185 | /** |
||
| 186 | * @param ServerRequestInterface $request |
||
| 187 | * |
||
| 188 | * @return ResponseInterface |
||
| 189 | */ |
||
| 190 | public function handle(ServerRequestInterface $request): ResponseInterface |
||
| 191 | { |
||
| 192 | $tree = Validator::attributes($request)->tree(); |
||
| 193 | $user = Validator::attributes($request)->user(); |
||
| 194 | $xref = Validator::attributes($request)->isXref()->string('xref'); |
||
| 195 | $style = Validator::attributes($request)->isInArrayKeys($this->styles())->integer('style'); |
||
| 196 | $generations = Validator::attributes($request)->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)->integer('generations'); |
||
| 197 | $width = Validator::attributes($request)->isBetween(self::MINIMUM_WIDTH, self::MAXIMUM_WIDTH)->integer('width'); |
||
| 198 | $ajax = Validator::queryParams($request)->boolean('ajax', false); |
||
| 199 | |||
| 200 | // Convert POST requests into GET requests for pretty URLs. |
||
| 201 | if ($request->getMethod() === RequestMethodInterface::METHOD_POST) { |
||
| 202 | return redirect(route(static::class, [ |
||
| 203 | 'tree' => $tree->name(), |
||
| 204 | 'generations' => Validator::parsedBody($request)->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)->integer('generations'), |
||
| 205 | 'style' => Validator::parsedBody($request)->isInArrayKeys($this->styles())->integer('style'), |
||
| 206 | 'width' => Validator::parsedBody($request)->isBetween(self::MINIMUM_WIDTH, self::MAXIMUM_WIDTH)->integer('width'), |
||
| 207 | 'xref' => Validator::parsedBody($request)->isXref()->string('xref'), |
||
| 208 | ])); |
||
| 209 | } |
||
| 210 | |||
| 211 | Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user); |
||
| 212 | |||
| 213 | $individual = Registry::individualFactory()->make($xref, $tree); |
||
| 214 | $individual = Auth::checkIndividualAccess($individual, false, true); |
||
| 215 | |||
| 216 | if ($ajax) { |
||
| 217 | return $this->chart($individual, $style, $width, $generations); |
||
| 218 | } |
||
| 219 | |||
| 220 | $ajax_url = $this->chartUrl($individual, [ |
||
| 221 | 'ajax' => true, |
||
| 222 | 'generations' => $generations, |
||
| 223 | 'style' => $style, |
||
| 224 | 'width' => $width, |
||
| 225 | ]); |
||
| 226 | |||
| 227 | return $this->viewResponse('modules/fanchart/page', [ |
||
| 228 | 'ajax_url' => $ajax_url, |
||
| 229 | 'generations' => $generations, |
||
| 230 | 'individual' => $individual, |
||
| 231 | 'maximum_generations' => self::MAXIMUM_GENERATIONS, |
||
| 232 | 'minimum_generations' => self::MINIMUM_GENERATIONS, |
||
| 233 | 'maximum_width' => self::MAXIMUM_WIDTH, |
||
| 234 | 'minimum_width' => self::MINIMUM_WIDTH, |
||
| 235 | 'module' => $this->name(), |
||
| 236 | 'style' => $style, |
||
| 237 | 'styles' => $this->styles(), |
||
| 238 | 'title' => $this->chartTitle($individual), |
||
| 239 | 'tree' => $tree, |
||
| 240 | 'width' => $width, |
||
| 241 | ]); |
||
| 242 | } |
||
| 243 | |||
| 244 | /** |
||
| 245 | * Generate both the HTML and PNG components of the fan chart |
||
| 246 | * |
||
| 247 | * @param Individual $individual |
||
| 248 | * @param int $style |
||
| 249 | * @param int $width |
||
| 250 | * @param int $generations |
||
| 251 | * |
||
| 252 | * @return ResponseInterface |
||
| 253 | */ |
||
| 254 | protected function chart(Individual $individual, int $style, int $width, int $generations): ResponseInterface |
||
| 255 | { |
||
| 256 | $ancestors = $this->chart_service->sosaStradonitzAncestors($individual, $generations); |
||
| 257 | |||
| 258 | $width = intdiv(self::CHART_WIDTH_PIXELS * $width, 100); |
||
| 259 | |||
| 260 | switch ($style) { |
||
| 261 | case self::STYLE_HALF_CIRCLE: |
||
| 262 | $chart_start_angle = 180; |
||
| 263 | $chart_end_angle = 360; |
||
| 264 | $height = intdiv($width, 2); |
||
| 265 | break; |
||
| 266 | |||
| 267 | case self::STYLE_THREE_QUARTER_CIRCLE: |
||
| 268 | $chart_start_angle = 135; |
||
| 269 | $chart_end_angle = 405; |
||
| 270 | $height = intdiv($width * 86, 100); |
||
| 271 | break; |
||
| 272 | |||
| 273 | case self::STYLE_FULL_CIRCLE: |
||
| 274 | default: |
||
| 275 | $chart_start_angle = 90; |
||
| 276 | $chart_end_angle = 450; |
||
| 277 | $height = $width; |
||
| 278 | break; |
||
| 279 | } |
||
| 280 | |||
| 281 | // Start with a transparent image. |
||
| 282 | $image = imagecreate($width, $height); |
||
| 283 | $transparent = imagecolorallocate($image, 0, 0, 0); |
||
| 284 | imagecolortransparent($image, $transparent); |
||
| 285 | imagefilledrectangle($image, 0, 0, $width, $height, $transparent); |
||
| 286 | |||
| 287 | // Use theme-specified colors. |
||
| 288 | $theme = Registry::container()->get(ModuleThemeInterface::class); |
||
| 289 | $text_color = $this->imageColor($image, '000000'); |
||
| 290 | $backgrounds = [ |
||
| 291 | 'M' => $this->imageColor($image, 'b1cff0'), |
||
| 292 | 'F' => $this->imageColor($image, 'e9daf1'), |
||
| 293 | 'U' => $this->imageColor($image, 'eeeeee'), |
||
| 294 | ]; |
||
| 295 | |||
| 296 | // Co-ordinates are measured from the top-left corner. |
||
| 297 | $center_x = intdiv($width, 2); |
||
| 298 | $center_y = $center_x; |
||
| 299 | $arc_width = $width / $generations / 2.0; |
||
| 300 | |||
| 301 | // Popup menus for each ancestor. |
||
| 302 | $html = ''; |
||
| 303 | |||
| 304 | // Areas for the image map. |
||
| 305 | $areas = ''; |
||
| 306 | |||
| 307 | for ($generation = $generations; $generation >= 1; $generation--) { |
||
| 308 | // Which ancestors to include in this ring. 1, 2-3, 4-7, 8-15, 16-31, etc. |
||
| 309 | // The end of the range is also the number of ancestors in the ring. |
||
| 310 | $sosa_start = 2 ** $generation - 1; |
||
| 311 | $sosa_end = 2 ** ($generation - 1); |
||
| 312 | |||
| 313 | $arc_diameter = intdiv($width * $generation, $generations); |
||
| 314 | $arc_radius = $arc_diameter / 2; |
||
| 315 | |||
| 316 | // Draw an empty background, for missing ancestors. |
||
| 317 | imagefilledarc( |
||
| 318 | $image, |
||
| 319 | $center_x, |
||
| 320 | $center_y, |
||
| 321 | $arc_diameter, |
||
| 322 | $arc_diameter, |
||
| 323 | $chart_start_angle, |
||
| 324 | $chart_end_angle, |
||
| 325 | $backgrounds['U'], |
||
| 326 | IMG_ARC_PIE |
||
| 327 | ); |
||
| 328 | |||
| 329 | $arc_diameter -= 2 * self::GAP_BETWEEN_RINGS; |
||
| 330 | |||
| 331 | for ($sosa = $sosa_start; $sosa >= $sosa_end; $sosa--) { |
||
| 332 | if ($ancestors->has($sosa)) { |
||
| 333 | $individual = $ancestors->get($sosa); |
||
| 334 | |||
| 335 | $chart_angle = $chart_end_angle - $chart_start_angle; |
||
| 336 | $start_angle = $chart_start_angle + intdiv($chart_angle * ($sosa - $sosa_end), $sosa_end); |
||
| 337 | $end_angle = $chart_start_angle + intdiv($chart_angle * ($sosa - $sosa_end + 1), $sosa_end); |
||
| 338 | $angle = $end_angle - $start_angle; |
||
| 339 | |||
| 340 | imagefilledarc( |
||
| 341 | $image, |
||
| 342 | $center_x, |
||
| 343 | $center_y, |
||
| 344 | $arc_diameter, |
||
| 345 | $arc_diameter, |
||
| 346 | $start_angle, |
||
| 347 | $end_angle, |
||
| 348 | $backgrounds[$individual->sex()] ?? $backgrounds['U'], |
||
| 349 | IMG_ARC_PIE |
||
| 350 | ); |
||
| 351 | |||
| 352 | // Text is written at a tangent to the arc. |
||
| 353 | $text_angle = 270.0 - ($start_angle + $end_angle) / 2.0; |
||
| 354 | |||
| 355 | $text_radius = $arc_diameter / 2.0 - $arc_width * 0.25; |
||
| 356 | |||
| 357 | // Don't draw text right up to the edge of the arc. |
||
| 358 | if ($angle === 360) { |
||
| 359 | $delta = 90; |
||
| 360 | } elseif ($angle === 180) { |
||
| 361 | if ($generation === 1) { |
||
| 362 | $delta = 20; |
||
| 363 | } else { |
||
| 364 | $delta = 60; |
||
| 365 | } |
||
| 366 | } elseif ($angle > 120) { |
||
| 367 | $delta = 45; |
||
| 368 | } elseif ($angle > 60) { |
||
| 369 | $delta = 15; |
||
| 370 | } else { |
||
| 371 | $delta = 1; |
||
| 372 | } |
||
| 373 | |||
| 374 | $tx_start = $center_x + $text_radius * cos(deg2rad($start_angle + $delta)); |
||
| 375 | $ty_start = $center_y + $text_radius * sin(deg2rad($start_angle + $delta)); |
||
| 376 | $tx_end = $center_x + $text_radius * cos(deg2rad($end_angle - $delta)); |
||
| 377 | $ty_end = $center_y + $text_radius * sin(deg2rad($end_angle - $delta)); |
||
| 378 | |||
| 379 | $max_text_length = (int) sqrt(($tx_end - $tx_start) ** 2 + ($ty_end - $ty_start) ** 2); |
||
| 380 | |||
| 381 | $text_lines = array_filter([ |
||
| 382 | I18N::reverseText($individual->fullName()), |
||
| 383 | I18N::reverseText($individual->alternateName() ?? ''), |
||
| 384 | I18N::reverseText($individual->lifespan()), |
||
| 385 | ]); |
||
| 386 | |||
| 387 | $text_lines = array_map( |
||
| 388 | fn (string $line): string => $this->fitTextToPixelWidth($line, $max_text_length), |
||
| 389 | $text_lines |
||
| 390 | ); |
||
| 391 | |||
| 392 | $text = implode("\n", $text_lines); |
||
| 393 | |||
| 394 | if ($generation === 1) { |
||
| 395 | $ty_start -= $text_radius / 2; |
||
| 396 | } |
||
| 397 | |||
| 398 | // If PHP is compiled with --enable-gd-jis-conv, then the function |
||
| 399 | // imagettftext() is modified to expect EUC-JP encoding instead of UTF-8. |
||
| 400 | // Attempt to detect and convert... |
||
| 401 | if (gd_info()['JIS-mapped Japanese Font Support'] ?? false) { |
||
| 402 | $text = mb_convert_encoding($text, 'EUC-JP', 'UTF-8'); |
||
| 403 | } |
||
| 404 | |||
| 405 | imagettftext( |
||
| 406 | $image, |
||
| 407 | self::TEXT_SIZE_POINTS, |
||
| 408 | $text_angle, |
||
| 409 | (int) $tx_start, |
||
| 410 | (int) $ty_start, |
||
| 411 | $text_color, |
||
| 412 | self::FONT, |
||
| 413 | $text |
||
| 414 | ); |
||
| 415 | // Debug text positions by underlining first line of text |
||
| 416 | //imageline($image, (int) $tx_start, (int) $ty_start, (int) $tx_end, (int) $ty_end, $backgrounds['U']); |
||
| 417 | |||
| 418 | $areas .= '<area shape="poly" coords="'; |
||
| 419 | for ($deg = $start_angle; $deg <= $end_angle; $deg++) { |
||
| 420 | $rad = deg2rad($deg); |
||
| 421 | $areas .= round($center_x + $arc_radius * cos($rad), 1) . ','; |
||
| 422 | $areas .= round($center_y + $arc_radius * sin($rad), 1) . ','; |
||
| 423 | } |
||
| 424 | for ($deg = $end_angle; $deg >= $start_angle; $deg--) { |
||
| 425 | $rad = deg2rad($deg); |
||
| 426 | $areas .= round($center_x + ($arc_radius - $arc_width) * cos($rad), 1) . ','; |
||
| 427 | $areas .= round($center_y + ($arc_radius - $arc_width) * sin($rad), 1) . ','; |
||
| 428 | } |
||
| 429 | $rad = deg2rad($start_angle); |
||
| 430 | $areas .= round($center_x + $arc_radius * cos($rad), 1) . ','; |
||
| 431 | $areas .= round($center_y + $arc_radius * sin($rad), 1) . '"'; |
||
| 432 | |||
| 433 | $areas .= ' href="#' . e($individual->xref()) . '"'; |
||
| 434 | $areas .= ' alt="' . strip_tags($individual->fullName()) . '"'; |
||
| 435 | $areas .= ' title="' . strip_tags($individual->fullName()) . '">'; |
||
| 436 | |||
| 437 | $html .= '<div id="' . $individual->xref() . '" class="fan_chart_menu">'; |
||
| 438 | $html .= '<a href="' . e($individual->url()) . '" class="dropdown-item p-1">'; |
||
| 439 | $html .= $individual->fullName(); |
||
| 440 | $html .= '</a>'; |
||
| 441 | |||
| 442 | foreach ($theme->individualBoxMenu($individual) as $menu) { |
||
| 443 | $link = $menu->getLink(); |
||
| 444 | $class = $menu->getClass(); |
||
| 445 | $html .= '<a href="' . e($link) . '" class="dropdown-item p-1 ' . e($class) . '">'; |
||
| 446 | $html .= $menu->getLabel(); |
||
| 447 | $html .= '</a>'; |
||
| 448 | } |
||
| 449 | |||
| 450 | $html .= '</div>'; |
||
| 451 | } |
||
| 452 | } |
||
| 453 | } |
||
| 454 | |||
| 455 | ob_start(); |
||
| 456 | imagepng($image); |
||
| 457 | $png = ob_get_clean(); |
||
| 458 | |||
| 459 | return response(view('modules/fanchart/chart', [ |
||
| 460 | 'fanh' => $height, |
||
| 461 | 'fanw' => $width, |
||
| 462 | 'html' => $html, |
||
| 463 | 'areas' => $areas, |
||
| 464 | 'png' => $png, |
||
| 465 | 'title' => $this->chartTitle($individual), |
||
| 466 | ])); |
||
| 467 | } |
||
| 468 | |||
| 469 | /** |
||
| 470 | * Convert a CSS color into a GD color. |
||
| 471 | * |
||
| 472 | * @param GdImage $image |
||
| 473 | * @param string $css_color |
||
| 474 | * |
||
| 475 | * @return int |
||
| 476 | */ |
||
| 477 | protected function imageColor(GdImage $image, string $css_color): int |
||
| 478 | { |
||
| 479 | return imagecolorallocate( |
||
| 480 | $image, |
||
| 481 | (int) hexdec(substr($css_color, 0, 2)), |
||
| 482 | (int) hexdec(substr($css_color, 2, 2)), |
||
| 483 | (int) hexdec(substr($css_color, 4, 2)) |
||
| 484 | ); |
||
| 485 | } |
||
| 486 | |||
| 487 | /** |
||
| 488 | * This chart can display its output in a number of styles |
||
| 489 | * |
||
| 490 | * @return array<string> |
||
| 491 | */ |
||
| 492 | protected function styles(): array |
||
| 493 | { |
||
| 494 | return [ |
||
| 495 | /* I18N: layout option for the fan chart */ |
||
| 496 | self::STYLE_HALF_CIRCLE => I18N::translate('half circle'), |
||
| 497 | /* I18N: layout option for the fan chart */ |
||
| 498 | self::STYLE_THREE_QUARTER_CIRCLE => I18N::translate('three-quarter circle'), |
||
| 499 | /* I18N: layout option for the fan chart */ |
||
| 500 | self::STYLE_FULL_CIRCLE => I18N::translate('full circle'), |
||
| 501 | ]; |
||
| 502 | } |
||
| 503 | |||
| 504 | /** |
||
| 505 | * Fit text to a given number of pixels by either cropping to fit, |
||
| 506 | * or adding spaces to center. |
||
| 507 | * |
||
| 508 | * @param string $text |
||
| 509 | * @param int $pixels |
||
| 510 | * |
||
| 511 | * @return string |
||
| 512 | */ |
||
| 513 | protected function fitTextToPixelWidth(string $text, int $pixels): string |
||
| 514 | { |
||
| 515 | while ($this->textWidthInPixels($text) > $pixels) { |
||
| 516 | $text = mb_substr($text, 0, -1); |
||
| 517 | } |
||
| 518 | |||
| 519 | while ($this->textWidthInPixels(' ' . $text . ' ') < $pixels) { |
||
| 520 | $text = ' ' . $text . ' '; |
||
| 521 | } |
||
| 522 | |||
| 523 | // We only need the leading spaces. |
||
| 524 | return rtrim($text); |
||
| 525 | } |
||
| 526 | |||
| 527 | /** |
||
| 528 | * @param string $text |
||
| 529 | * |
||
| 530 | * @return int |
||
| 531 | */ |
||
| 532 | protected function textWidthInPixels(string $text): int |
||
| 533 | { |
||
| 534 | // If PHP is compiled with --enable-gd-jis-conv, then the function |
||
| 535 | // imagettftext() is modified to expect EUC-JP encoding instead of UTF-8. |
||
| 536 | // Attempt to detect and convert... |
||
| 537 | if (gd_info()['JIS-mapped Japanese Font Support'] ?? false) { |
||
| 538 | $text = mb_convert_encoding($text, 'EUC-JP', 'UTF-8'); |
||
| 539 | } |
||
| 540 | |||
| 541 | $bounding_box = imagettfbbox(self::TEXT_SIZE_POINTS, 0, self::FONT, $text); |
||
| 542 | |||
| 543 | return $bounding_box[4] - $bounding_box[0]; |
||
| 544 | } |
||
| 545 | } |
||
| 546 |