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