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