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