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); 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 return redirect(route(self::ROUTE_NAME, [ 208 'tree' => $tree->name(), 209 'xref' => $request->getParsedBody()['xref'], 210 'style' => $request->getParsedBody()['style'], 211 'generations' => $request->getParsedBody()['generations'], 212 'width' => $request->getParsedBody()['width'], 213 ])); 214 } 215 216 Auth::checkComponentAccess($this, 'chart', $tree, $user); 217 218 $width = min($width, self::MAXIMUM_WIDTH); 219 $width = max($width, self::MINIMUM_WIDTH); 220 221 $generations = min($generations, self::MAXIMUM_GENERATIONS); 222 $generations = max($generations, self::MINIMUM_GENERATIONS); 223 224 if ($ajax === '1') { 225 return $this->chart($individual, $style, $width, $generations); 226 } 227 228 $ajax_url = $this->chartUrl($individual, [ 229 'ajax' => true, 230 'generations' => $generations, 231 'style' => $style, 232 'width' => $width, 233 ]); 234 235 return $this->viewResponse('modules/fanchart/page', [ 236 'ajax_url' => $ajax_url, 237 'generations' => $generations, 238 'individual' => $individual, 239 'maximum_generations' => self::MAXIMUM_GENERATIONS, 240 'minimum_generations' => self::MINIMUM_GENERATIONS, 241 'maximum_width' => self::MAXIMUM_WIDTH, 242 'minimum_width' => self::MINIMUM_WIDTH, 243 'module' => $this->name(), 244 'style' => $style, 245 'styles' => $this->styles(), 246 'title' => $this->chartTitle($individual), 247 'tree' => $tree, 248 'width' => $width, 249 ]); 250 } 251 252 /** 253 * Generate both the HTML and PNG components of the fan chart 254 * 255 * @param Individual $individual 256 * @param string $style 257 * @param int $width 258 * @param int $generations 259 * 260 * @return ResponseInterface 261 */ 262 protected function chart(Individual $individual, string $style, int $width, int $generations): ResponseInterface 263 { 264 $ancestors = $this->chart_service->sosaStradonitzAncestors($individual, $generations); 265 266 $gen = $generations - 1; 267 $sosa = 2 ** $generations - 1; 268 269 // fan size 270 $fanw = 640 * $width / 100; 271 $cx = $fanw / 2 - 1; // center x 272 $cy = $cx; // center y 273 $rx = $fanw - 1; 274 $rw = $fanw / ($gen + 1); 275 $fanh = $fanw; // fan height 276 if ($style === self::STYLE_HALF_CIRCLE) { 277 $fanh = $fanh * ($gen + 1) / ($gen * 2); 278 } 279 if ($style === self::STYLE_THREE_QUARTER_CIRCLE) { 280 $fanh *= 0.86; 281 } 282 $scale = $fanw / 640; 283 284 // Create the image 285 $image = imagecreate((int) $fanw, (int) $fanh); 286 287 // Create colors 288 $transparent = imagecolorallocate($image, 0, 0, 0); 289 imagecolortransparent($image, $transparent); 290 291 /** @var ModuleThemeInterface $theme */ 292 $theme = app(ModuleThemeInterface::class); 293 294 $foreground = $this->imageColor($image, $theme->parameter('chart-font-color')); 295 296 $backgrounds = [ 297 'M' => $this->imageColor($image, $theme->parameter('chart-background-m')), 298 'F' => $this->imageColor($image, $theme->parameter('chart-background-f')), 299 'U' => $this->imageColor($image, $theme->parameter('chart-background-u')), 300 ]; 301 302 imagefilledrectangle($image, 0, 0, (int) $fanw, (int) $fanh, $transparent); 303 304 $fandeg = 90 * $style; 305 306 // Popup menus for each ancestor 307 $html = ''; 308 309 // Areas for the imagemap 310 $areas = ''; 311 312 // loop to create fan cells 313 while ($gen >= 0) { 314 // clean current generation area 315 $deg2 = 360 + ($fandeg - 180) / 2; 316 $deg1 = $deg2 - $fandeg; 317 imagefilledarc($image, (int) $cx, (int) $cy, (int) $rx, (int) $rx, (int) $deg1, (int) $deg2, $backgrounds['U'], IMG_ARC_PIE); 318 $rx -= 3; 319 320 // calculate new angle 321 $p2 = 2 ** $gen; 322 $angle = $fandeg / $p2; 323 $deg2 = 360 + ($fandeg - 180) / 2; 324 $deg1 = $deg2 - $angle; 325 // special case for rootid cell 326 if ($gen == 0) { 327 $deg1 = 90; 328 $deg2 = 360 + $deg1; 329 } 330 331 // draw each cell 332 while ($sosa >= $p2) { 333 if ($ancestors->has($sosa)) { 334 $person = $ancestors->get($sosa); 335 $name = $person->fullName(); 336 $addname = $person->alternateName(); 337 338 $text = I18N::reverseText($name); 339 if ($addname) { 340 $text .= "\n" . I18N::reverseText($addname); 341 } 342 343 $text .= "\n" . I18N::reverseText($person->lifespan()); 344 345 $background = $backgrounds[$person->sex()]; 346 347 imagefilledarc($image, (int) $cx, (int) $cy, (int) $rx, (int) $rx, (int) $deg1, (int) $deg2, $background, IMG_ARC_PIE); 348 349 // split and center text by lines 350 $wmax = (int) ($angle * 7 / 7 * $scale); 351 $wmax = min($wmax, 35 * $scale); 352 if ($gen === 0) { 353 $wmax = min($wmax, 17 * $scale); 354 } 355 $text = $this->splitAlignText($text, (int) $wmax); 356 357 // text angle 358 $tangle = 270 - ($deg1 + $angle / 2); 359 if ($gen === 0) { 360 $tangle = 0; 361 } 362 363 // calculate text position 364 $deg = $deg1 + 0.44; 365 if ($deg2 - $deg1 > 40) { 366 $deg = $deg1 + ($deg2 - $deg1) / 11; 367 } 368 if ($deg2 - $deg1 > 80) { 369 $deg = $deg1 + ($deg2 - $deg1) / 7; 370 } 371 if ($deg2 - $deg1 > 140) { 372 $deg = $deg1 + ($deg2 - $deg1) / 4; 373 } 374 if ($gen === 0) { 375 $deg = 180; 376 } 377 $rad = deg2rad($deg); 378 $mr = ($rx - $rw / 4) / 2; 379 if ($gen > 0 && $deg2 - $deg1 > 80) { 380 $mr = $rx / 2; 381 } 382 $tx = $cx + $mr * cos($rad); 383 $ty = $cy + $mr * sin($rad); 384 if ($sosa === 1) { 385 $ty -= $mr / 2; 386 } 387 388 // print text 389 imagettftext( 390 $image, 391 7, 392 $tangle, 393 (int) $tx, 394 (int) $ty, 395 $foreground, 396 Webtrees::ROOT_DIR . 'resources/fonts/DejaVuSans.ttf', 397 $text 398 ); 399 400 $areas .= '<area shape="poly" coords="'; 401 // plot upper points 402 $mr = $rx / 2; 403 $deg = $deg1; 404 while ($deg <= $deg2) { 405 $rad = deg2rad($deg); 406 $tx = round($cx + $mr * cos($rad)); 407 $ty = round($cy + $mr * sin($rad)); 408 $areas .= "$tx,$ty,"; 409 $deg += ($deg2 - $deg1) / 6; 410 } 411 // plot lower points 412 $mr = ($rx - $rw) / 2; 413 $deg = $deg2; 414 while ($deg >= $deg1) { 415 $rad = deg2rad($deg); 416 $tx = round($cx + $mr * cos($rad)); 417 $ty = round($cy + $mr * sin($rad)); 418 $areas .= "$tx,$ty,"; 419 $deg -= ($deg2 - $deg1) / 6; 420 } 421 // join first point 422 $mr = $rx / 2; 423 $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 // add action url 429 $areas .= '" href="#' . $person->xref() . '"'; 430 $html .= '<div id="' . $person->xref() . '" class="fan_chart_menu">'; 431 $html .= '<div class="person_box"><div class="small">'; 432 $html .= '<div class="charts">'; 433 $html .= '<a href="' . e($person->url()) . '" class="dropdown-item">' . $name . '</a>'; 434 foreach ($theme->individualBoxMenu($person) as $menu) { 435 $html .= '<a href="' . e($menu->getLink()) . '" class="dropdown-item p-1 ' . e($menu->getClass()) . '">' . $menu->getLabel() . '</a>'; 436 } 437 $html .= '</div>'; 438 $html .= '</div></div>'; 439 $html .= '</div>'; 440 $areas .= ' alt="' . strip_tags($person->fullName()) . '" title="' . strip_tags($person->fullName()) . '">'; 441 } 442 $deg1 -= $angle; 443 $deg2 -= $angle; 444 $sosa--; 445 } 446 $rx -= $rw; 447 $gen--; 448 } 449 450 ob_start(); 451 imagepng($image); 452 imagedestroy($image); 453 $png = ob_get_clean(); 454 455 return response(view('modules/fanchart/chart', [ 456 'fanh' => $fanh, 457 'fanw' => $fanw, 458 'html' => $html, 459 'areas' => $areas, 460 'png' => $png, 461 'title' => $this->chartTitle($individual), 462 ])); 463 } 464 465 /** 466 * split and center text by lines 467 * 468 * @param string $data input string 469 * @param int $maxlen max length of each line 470 * 471 * @return string $text output string 472 */ 473 protected function splitAlignText(string $data, int $maxlen): string 474 { 475 $RTLOrd = [ 476 215, 477 216, 478 217, 479 218, 480 219, 481 ]; 482 483 $lines = explode("\n", $data); 484 // more than 1 line : recursive calls 485 if (count($lines) > 1) { 486 $text = ''; 487 foreach ($lines as $line) { 488 $text .= $this->splitAlignText($line, $maxlen) . "\n"; 489 } 490 491 return $text; 492 } 493 // process current line word by word 494 $split = explode(' ', $data); 495 $text = ''; 496 $line = ''; 497 498 // do not split hebrew line 499 $found = false; 500 foreach ($RTLOrd as $ord) { 501 if (strpos($data, chr($ord)) !== false) { 502 $found = true; 503 } 504 } 505 if ($found) { 506 $line = $data; 507 } else { 508 foreach ($split as $word) { 509 $len = strlen($line); 510 $wlen = strlen($word); 511 if (($len + $wlen) < $maxlen) { 512 if ($line !== '') { 513 $line .= ' '; 514 } 515 $line .= $word; 516 } else { 517 $p = max(0, (int) (($maxlen - $len) / 2)); 518 if ($line !== '') { 519 $line = str_repeat(' ', $p) . $line; // center alignment using spaces 520 $text .= $line . "\n"; 521 } 522 $line = $word; 523 } 524 } 525 } 526 // last line 527 if ($line !== '') { 528 $len = strlen($line); 529 if (in_array(ord($line[0]), $RTLOrd, true)) { 530 $len /= 2; 531 } 532 $p = max(0, (int) (($maxlen - $len) / 2)); 533 $line = str_repeat(' ', $p) . $line; // center alignment using spaces 534 $text .= $line; 535 } 536 537 return $text; 538 } 539 540 /** 541 * Convert a CSS color into a GD color. 542 * 543 * @param resource $image 544 * @param string $css_color 545 * 546 * @return int 547 */ 548 protected function imageColor($image, string $css_color): int 549 { 550 return imagecolorallocate( 551 $image, 552 (int) hexdec(substr($css_color, 0, 2)), 553 (int) hexdec(substr($css_color, 2, 2)), 554 (int) hexdec(substr($css_color, 4, 2)) 555 ); 556 } 557 558 /** 559 * This chart can display its output in a number of styles 560 * 561 * @return array 562 */ 563 protected function styles(): array 564 { 565 return [ 566 /* I18N: layout option for the fan chart */ 567 self::STYLE_HALF_CIRCLE => I18N::translate('half circle'), 568 /* I18N: layout option for the fan chart */ 569 self::STYLE_THREE_QUARTER_CIRCLE => I18N::translate('three-quarter circle'), 570 /* I18N: layout option for the fan chart */ 571 self::STYLE_FULL_CIRCLE => I18N::translate('full circle'), 572 ]; 573 } 574} 575