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