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