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