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