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