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