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