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