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) 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 $theme = app(ModuleThemeInterface::class); 278 279 $foreground = $this->imageColor($image, $theme->parameter('chart-font-color')); 280 281 $backgrounds = [ 282 'M' => $this->imageColor($image, $theme->parameter('chart-background-m')), 283 'F' => $this->imageColor($image, $theme->parameter('chart-background-f')), 284 'U' => $this->imageColor($image, $theme->parameter('chart-background-u')), 285 ]; 286 287 imagefilledrectangle($image, 0, 0, (int) $fanw, (int) $fanh, $transparent); 288 289 $fandeg = 90 * $style; 290 291 // Popup menus for each ancestor 292 $html = ''; 293 294 // Areas for the imagemap 295 $areas = ''; 296 297 // loop to create fan cells 298 while ($gen >= 0) { 299 // clean current generation area 300 $deg2 = 360 + ($fandeg - 180) / 2; 301 $deg1 = $deg2 - $fandeg; 302 imagefilledarc($image, (int) $cx, (int) $cy, (int) $rx, (int) $rx, (int) $deg1, (int) $deg2, $backgrounds['U'], IMG_ARC_PIE); 303 $rx -= 3; 304 305 // calculate new angle 306 $p2 = 2 ** $gen; 307 $angle = $fandeg / $p2; 308 $deg2 = 360 + ($fandeg - 180) / 2; 309 $deg1 = $deg2 - $angle; 310 // special case for rootid cell 311 if ($gen == 0) { 312 $deg1 = 90; 313 $deg2 = 360 + $deg1; 314 } 315 316 // draw each cell 317 while ($sosa >= $p2) { 318 if ($ancestors->has($sosa)) { 319 $person = $ancestors->get($sosa); 320 $name = $person->fullName(); 321 $addname = $person->alternateName(); 322 323 $text = I18N::reverseText($name); 324 if ($addname) { 325 $text .= "\n" . I18N::reverseText($addname); 326 } 327 328 $text .= "\n" . I18N::reverseText($person->getLifeSpan()); 329 330 $background = $backgrounds[$person->sex()]; 331 332 imagefilledarc($image, (int) $cx, (int) $cy, (int) $rx, (int) $rx, (int) $deg1, (int) $deg2, $background, IMG_ARC_PIE); 333 334 // split and center text by lines 335 $wmax = (int) ($angle * 7 / 7 * $scale); 336 $wmax = min($wmax, 35 * $scale); 337 if ($gen === 0) { 338 $wmax = min($wmax, 17 * $scale); 339 } 340 $text = $this->splitAlignText($text, (int) $wmax); 341 342 // text angle 343 $tangle = 270 - ($deg1 + $angle / 2); 344 if ($gen === 0) { 345 $tangle = 0; 346 } 347 348 // calculate text position 349 $deg = $deg1 + 0.44; 350 if ($deg2 - $deg1 > 40) { 351 $deg = $deg1 + ($deg2 - $deg1) / 11; 352 } 353 if ($deg2 - $deg1 > 80) { 354 $deg = $deg1 + ($deg2 - $deg1) / 7; 355 } 356 if ($deg2 - $deg1 > 140) { 357 $deg = $deg1 + ($deg2 - $deg1) / 4; 358 } 359 if ($gen === 0) { 360 $deg = 180; 361 } 362 $rad = deg2rad($deg); 363 $mr = ($rx - $rw / 4) / 2; 364 if ($gen > 0 && $deg2 - $deg1 > 80) { 365 $mr = $rx / 2; 366 } 367 $tx = $cx + $mr * cos($rad); 368 $ty = $cy + $mr * sin($rad); 369 if ($sosa === 1) { 370 $ty -= $mr / 2; 371 } 372 373 // print text 374 imagettftext( 375 $image, 376 7, 377 $tangle, 378 (int) $tx, 379 (int) $ty, 380 $foreground, 381 Webtrees::ROOT_DIR . 'resources/fonts/DejaVuSans.ttf', 382 $text 383 ); 384 385 $areas .= '<area shape="poly" coords="'; 386 // plot upper points 387 $mr = $rx / 2; 388 $deg = $deg1; 389 while ($deg <= $deg2) { 390 $rad = deg2rad($deg); 391 $tx = round($cx + $mr * cos($rad)); 392 $ty = round($cy + $mr * sin($rad)); 393 $areas .= "$tx,$ty,"; 394 $deg += ($deg2 - $deg1) / 6; 395 } 396 // plot lower points 397 $mr = ($rx - $rw) / 2; 398 $deg = $deg2; 399 while ($deg >= $deg1) { 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 // join first point 407 $mr = $rx / 2; 408 $deg = $deg1; 409 $rad = deg2rad($deg); 410 $tx = round($cx + $mr * cos($rad)); 411 $ty = round($cy + $mr * sin($rad)); 412 $areas .= "$tx,$ty"; 413 // add action url 414 $areas .= '" href="#' . $person->xref() . '"'; 415 $html .= '<div id="' . $person->xref() . '" class="fan_chart_menu">'; 416 $html .= '<div class="person_box"><div class="details1">'; 417 $html .= '<div class="charts">'; 418 $html .= '<a href="' . e($person->url()) . '" class="dropdown-item">' . $name . '</a>'; 419 foreach ($theme->individualBoxMenu($person) as $menu) { 420 $html .= '<a href="' . e($menu->getLink()) . '" class="dropdown-item p-1 ' . e($menu->getClass()) . '">' . $menu->getLabel() . '</a>'; 421 } 422 $html .= '</div>'; 423 $html .= '</div></div>'; 424 $html .= '</div>'; 425 $areas .= ' alt="' . strip_tags($person->fullName()) . '" title="' . strip_tags($person->fullName()) . '">'; 426 } 427 $deg1 -= $angle; 428 $deg2 -= $angle; 429 $sosa--; 430 } 431 $rx -= $rw; 432 $gen--; 433 } 434 435 ob_start(); 436 imagepng($image); 437 imagedestroy($image); 438 $png = ob_get_clean(); 439 440 return response(view('modules/fanchart/chart', [ 441 'fanh' => $fanh, 442 'fanw' => $fanw, 443 'html' => $html, 444 'areas' => $areas, 445 'png' => $png, 446 'title' => $this->chartTitle($individual), 447 ])); 448 } 449 450 /** 451 * split and center text by lines 452 * 453 * @param string $data input string 454 * @param int $maxlen max length of each line 455 * 456 * @return string $text output string 457 */ 458 protected function splitAlignText(string $data, int $maxlen): string 459 { 460 $RTLOrd = [ 461 215, 462 216, 463 217, 464 218, 465 219, 466 ]; 467 468 $lines = explode("\n", $data); 469 // more than 1 line : recursive calls 470 if (count($lines) > 1) { 471 $text = ''; 472 foreach ($lines as $line) { 473 $text .= $this->splitAlignText($line, $maxlen) . "\n"; 474 } 475 476 return $text; 477 } 478 // process current line word by word 479 $split = explode(' ', $data); 480 $text = ''; 481 $line = ''; 482 483 // do not split hebrew line 484 $found = false; 485 foreach ($RTLOrd as $ord) { 486 if (strpos($data, chr($ord)) !== false) { 487 $found = true; 488 } 489 } 490 if ($found) { 491 $line = $data; 492 } else { 493 foreach ($split as $word) { 494 $len = strlen($line); 495 $wlen = strlen($word); 496 if (($len + $wlen) < $maxlen) { 497 if (!empty($line)) { 498 $line .= ' '; 499 } 500 $line .= $word; 501 } else { 502 $p = max(0, (int) (($maxlen - $len) / 2)); 503 if (!empty($line)) { 504 $line = str_repeat(' ', $p) . $line; // center alignment using spaces 505 $text .= $line . "\n"; 506 } 507 $line = $word; 508 } 509 } 510 } 511 // last line 512 if (!empty($line)) { 513 $len = strlen($line); 514 if (in_array(ord($line[0]), $RTLOrd, true)) { 515 $len /= 2; 516 } 517 $p = max(0, (int) (($maxlen - $len) / 2)); 518 $line = str_repeat(' ', $p) . $line; // center alignment using spaces 519 $text .= $line; 520 } 521 522 return $text; 523 } 524 525 /** 526 * Convert a CSS color into a GD color. 527 * 528 * @param resource $image 529 * @param string $css_color 530 * 531 * @return int 532 */ 533 protected function imageColor($image, string $css_color): int 534 { 535 return imagecolorallocate( 536 $image, 537 (int) hexdec(substr($css_color, 0, 2)), 538 (int) hexdec(substr($css_color, 2, 2)), 539 (int) hexdec(substr($css_color, 4, 2)) 540 ); 541 } 542 543 /** 544 * This chart can display its output in a number of styles 545 * 546 * @return array 547 */ 548 protected function styles(): array 549 { 550 return [ 551 /* I18N: layout option for the fan chart */ 552 self::STYLE_HALF_CIRCLE => I18N::translate('half circle'), 553 /* I18N: layout option for the fan chart */ 554 self::STYLE_THREE_QUARTER_CIRCLE => I18N::translate('three-quarter circle'), 555 /* I18N: layout option for the fan chart */ 556 self::STYLE_FULL_CIRCLE => I18N::translate('full circle'), 557 ]; 558 } 559} 560