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\Theme; 26use Fisharebest\Webtrees\Tree; 27use Fisharebest\Webtrees\User; 28use Symfony\Component\HttpFoundation\Request; 29use Symfony\Component\HttpFoundation\Response; 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 labelled on tabs, menus, 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->getFullName()); 109 } 110 111 /** 112 * A form to request the chart parameters. 113 * 114 * @param Request $request 115 * @param Tree $tree 116 * @param User $user 117 * @param ChartService $chart_service 118 * 119 * @return Response 120 */ 121 public function getChartAction(Request $request, Tree $tree, User $user, ChartService $chart_service): Response 122 { 123 $ajax = (bool) $request->get('ajax'); 124 $xref = $request->get('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->get('chart_style', self::DEFAULT_STYLE); 131 $fan_width = (int) $request->get('fan_width', self::DEFAULT_WIDTH); 132 $generations = (int) $request->get('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) { 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 Response 177 */ 178 protected function chart(Individual $individual, int $chart_style, int $fan_width, int $generations, ChartService $chart_service): Response 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 = $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()->make(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 218 imagefilledrectangle($image, 0, 0, (int) $fanw, (int) $fanh, $transparent); 219 220 $fandeg = 90 * $chart_style; 221 222 // Popup menus for each ancestor 223 $html = ''; 224 225 // Areas for the imagemap 226 $areas = ''; 227 228 // loop to create fan cells 229 while ($gen >= 0) { 230 // clean current generation area 231 $deg2 = 360 + ($fandeg - 180) / 2; 232 $deg1 = $deg2 - $fandeg; 233 imagefilledarc($image, (int) $cx, (int) $cy, (int) $rx, (int) $rx, (int) $deg1, (int) $deg2, $backgrounds['U'], IMG_ARC_PIE); 234 $rx -= 3; 235 236 // calculate new angle 237 $p2 = 2 ** $gen; 238 $angle = $fandeg / $p2; 239 $deg2 = 360 + ($fandeg - 180) / 2; 240 $deg1 = $deg2 - $angle; 241 // special case for rootid cell 242 if ($gen == 0) { 243 $deg1 = 90; 244 $deg2 = 360 + $deg1; 245 } 246 247 // draw each cell 248 while ($sosa >= $p2) { 249 if ($ancestors->has($sosa)) { 250 $person = $ancestors->get($sosa); 251 $name = $person->getFullName(); 252 $addname = $person->getAddName(); 253 254 $text = I18N::reverseText($name); 255 if ($addname) { 256 $text .= "\n" . I18N::reverseText($addname); 257 } 258 259 $text .= "\n" . I18N::reverseText($person->getLifeSpan()); 260 261 $background = $backgrounds[$person->getSex()]; 262 263 imagefilledarc($image, (int) $cx, (int) $cy, (int) $rx, (int) $rx, (int) $deg1, (int) $deg2, $background, IMG_ARC_PIE); 264 265 // split and center text by lines 266 $wmax = (int) ($angle * 7 / 7 * $scale); 267 $wmax = min($wmax, 35 * $scale); 268 if ($gen == 0) { 269 $wmax = min($wmax, 17 * $scale); 270 } 271 $text = $this->splitAlignText($text, (int) $wmax); 272 273 // text angle 274 $tangle = 270 - ($deg1 + $angle / 2); 275 if ($gen == 0) { 276 $tangle = 0; 277 } 278 279 // calculate text position 280 $deg = $deg1 + 0.44; 281 if ($deg2 - $deg1 > 40) { 282 $deg = $deg1 + ($deg2 - $deg1) / 11; 283 } 284 if ($deg2 - $deg1 > 80) { 285 $deg = $deg1 + ($deg2 - $deg1) / 7; 286 } 287 if ($deg2 - $deg1 > 140) { 288 $deg = $deg1 + ($deg2 - $deg1) / 4; 289 } 290 if ($gen == 0) { 291 $deg = 180; 292 } 293 $rad = deg2rad($deg); 294 $mr = ($rx - $rw / 4) / 2; 295 if ($gen > 0 && $deg2 - $deg1 > 80) { 296 $mr = $rx / 2; 297 } 298 $tx = $cx + $mr * cos($rad); 299 $ty = $cy + $mr * sin($rad); 300 if ($sosa == 1) { 301 $ty -= $mr / 2; 302 } 303 304 // print text 305 imagettftext( 306 $image, 307 7, 308 $tangle, 309 (int) $tx, 310 (int) $ty, 311 $foreground, 312 WT_ROOT . 'resources/fonts/DejaVuSans.ttf', 313 $text 314 ); 315 316 $areas .= '<area shape="poly" coords="'; 317 // plot upper points 318 $mr = $rx / 2; 319 $deg = $deg1; 320 while ($deg <= $deg2) { 321 $rad = deg2rad($deg); 322 $tx = round($cx + $mr * cos($rad)); 323 $ty = round($cy + $mr * sin($rad)); 324 $areas .= "$tx,$ty,"; 325 $deg += ($deg2 - $deg1) / 6; 326 } 327 // plot lower points 328 $mr = ($rx - $rw) / 2; 329 $deg = $deg2; 330 while ($deg >= $deg1) { 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 // join first point 338 $mr = $rx / 2; 339 $deg = $deg1; 340 $rad = deg2rad($deg); 341 $tx = round($cx + $mr * cos($rad)); 342 $ty = round($cy + $mr * sin($rad)); 343 $areas .= "$tx,$ty"; 344 // add action url 345 $areas .= '" href="#' . $person->xref() . '"'; 346 $html .= '<div id="' . $person->xref() . '" class="fan_chart_menu">'; 347 $html .= '<div class="person_box"><div class="details1">'; 348 $html .= '<a href="' . e($person->url()) . '" class="name1">' . $name; 349 if ($addname) { 350 $html .= $addname; 351 } 352 $html .= '</a>'; 353 $html .= '<ul class="charts">'; 354 foreach ($theme->individualBoxMenu($person) as $menu) { 355 $html .= $menu->getMenuAsList(); 356 } 357 $html .= '</ul>'; 358 $html .= '</div></div>'; 359 $html .= '</div>'; 360 $areas .= ' alt="' . strip_tags($person->getFullName()) . '" title="' . strip_tags($person->getFullName()) . '">'; 361 } 362 $deg1 -= $angle; 363 $deg2 -= $angle; 364 $sosa--; 365 } 366 $rx -= $rw; 367 $gen--; 368 } 369 370 ob_start(); 371 imagepng($image); 372 imagedestroy($image); 373 $png = ob_get_clean(); 374 375 return new Response(view('modules/fanchart/chart', [ 376 'fanh' => $fanh, 377 'fanw' => $fanw, 378 'html' => $html, 379 'areas' => $areas, 380 'png' => $png, 381 'title' => $this->chartTitle($individual), 382 ])); 383 } 384 385 /** 386 * split and center text by lines 387 * 388 * @param string $data input string 389 * @param int $maxlen max length of each line 390 * 391 * @return string $text output string 392 */ 393 protected function splitAlignText(string $data, int $maxlen): string 394 { 395 $RTLOrd = [ 396 215, 397 216, 398 217, 399 218, 400 219, 401 ]; 402 403 $lines = explode("\n", $data); 404 // more than 1 line : recursive calls 405 if (count($lines) > 1) { 406 $text = ''; 407 foreach ($lines as $line) { 408 $text .= $this->splitAlignText($line, $maxlen) . "\n"; 409 } 410 411 return $text; 412 } 413 // process current line word by word 414 $split = explode(' ', $data); 415 $text = ''; 416 $line = ''; 417 418 // do not split hebrew line 419 $found = false; 420 foreach ($RTLOrd as $ord) { 421 if (strpos($data, chr($ord)) !== false) { 422 $found = true; 423 } 424 } 425 if ($found) { 426 $line = $data; 427 } else { 428 foreach ($split as $word) { 429 $len = strlen($line); 430 $wlen = strlen($word); 431 if (($len + $wlen) < $maxlen) { 432 if (!empty($line)) { 433 $line .= ' '; 434 } 435 $line .= "$word"; 436 } else { 437 $p = max(0, (int) (($maxlen - $len) / 2)); 438 if (!empty($line)) { 439 $line = str_repeat(' ', $p) . $line; // center alignment using spaces 440 $text .= $line . "\n"; 441 } 442 $line = $word; 443 } 444 } 445 } 446 // last line 447 if (!empty($line)) { 448 $len = strlen($line); 449 if (in_array(ord($line{0}), $RTLOrd)) { 450 $len /= 2; 451 } 452 $p = max(0, (int) (($maxlen - $len) / 2)); 453 $line = str_repeat(' ', $p) . $line; // center alignment using spaces 454 $text .= $line; 455 } 456 457 return $text; 458 } 459 460 /** 461 * Convert a CSS color into a GD color. 462 * 463 * @param resource $image 464 * @param string $css_color 465 * 466 * @return int 467 */ 468 protected function imageColor($image, string $css_color): int 469 { 470 return imagecolorallocate( 471 $image, 472 (int) hexdec(substr($css_color, 0, 2)), 473 (int) hexdec(substr($css_color, 2, 2)), 474 (int) hexdec(substr($css_color, 4, 2)) 475 ); 476 } 477 478 /** 479 * This chart can display its output in a number of styles 480 * 481 * @return array 482 */ 483 protected function chartStyles(): array 484 { 485 return [ 486 /* I18N: layout option for the fan chart */ 487 self::STYLE_HALF_CIRCLE => I18N::translate('half circle'), 488 /* I18N: layout option for the fan chart */ 489 self::STYLE_THREE_QUARTER_CIRCLE => I18N::translate('three-quarter circle'), 490 /* I18N: layout option for the fan chart */ 491 self::STYLE_FULL_CIRCLE => I18N::translate('full circle'), 492 ]; 493 } 494} 495