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