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