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