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