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