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\Tree; 26use Fisharebest\Webtrees\User; 27use Symfony\Component\HttpFoundation\Request; 28use Symfony\Component\HttpFoundation\Response; 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 /** 54 * How should this module be labelled on tabs, menus, etc.? 55 * 56 * @return string 57 */ 58 public function title(): string 59 { 60 /* I18N: Name of a module/chart */ 61 return I18N::translate('Fan chart'); 62 } 63 64 /** 65 * A sentence describing what this module does. 66 * 67 * @return string 68 */ 69 public function description(): string 70 { 71 /* I18N: Description of the “Fan Chart” module */ 72 return I18N::translate('A fan chart of an individual’s ancestors.'); 73 } 74 75 /** 76 * CSS class for the URL. 77 * 78 * @return string 79 */ 80 public function chartMenuClass(): string 81 { 82 return 'menu-chart-fanchart'; 83 } 84 85 /** 86 * Return a menu item for this chart - for use in individual boxes. 87 * 88 * @param Individual $individual 89 * 90 * @return Menu|null 91 */ 92 public function chartBoxMenu(Individual $individual): ?Menu 93 { 94 return $this->chartMenu($individual); 95 } 96 97 /** 98 * The title for a specific instance of this chart. 99 * 100 * @param Individual $individual 101 * 102 * @return string 103 */ 104 public function chartTitle(Individual $individual): string 105 { 106 /* I18N: http://en.wikipedia.org/wiki/Family_tree#Fan_chart - %s is an individual’s name */ 107 return I18N::translate('Fan chart of %s', $individual->getFullName()); 108 } 109 110 /** 111 * A form to request the chart parameters. 112 * 113 * @param Request $request 114 * @param Tree $tree 115 * @param User $user 116 * @param ChartService $chart_service 117 * 118 * @return Response 119 */ 120 public function getChartAction(Request $request, Tree $tree, User $user, ChartService $chart_service): Response 121 { 122 $ajax = (bool) $request->get('ajax'); 123 $xref = $request->get('xref', ''); 124 $individual = Individual::getInstance($xref, $tree); 125 126 Auth::checkIndividualAccess($individual); 127 Auth::checkComponentAccess($this, 'chart', $tree, $user); 128 129 $chart_style = (int) $request->get('chart_style', self::DEFAULT_STYLE); 130 $fan_width = (int) $request->get('fan_width', self::DEFAULT_WIDTH); 131 $generations = (int) $request->get('generations', self::DEFAULT_GENERATIONS); 132 133 $fan_width = min($fan_width, self::MAXIMUM_WIDTH); 134 $fan_width = max($fan_width, self::MINIMUM_WIDTH); 135 136 $generations = min($generations, self::MAXIMUM_GENERATIONS); 137 $generations = max($generations, self::MINIMUM_GENERATIONS); 138 139 if ($ajax) { 140 return $this->chart($individual, $chart_style, $fan_width, $generations, $chart_service); 141 } 142 143 $ajax_url = $this->chartUrl($individual, [ 144 'ajax' => true, 145 'chart_style' => $chart_style, 146 'fan_width' => $fan_width, 147 'generations' => $generations, 148 ]); 149 150 return $this->viewResponse('modules/fanchart/page', [ 151 'ajax_url' => $ajax_url, 152 'chart_style' => $chart_style, 153 'chart_styles' => $this->chartStyles(), 154 'fan_width' => $fan_width, 155 'generations' => $generations, 156 'individual' => $individual, 157 'maximum_generations' => self::MAXIMUM_GENERATIONS, 158 'minimum_generations' => self::MINIMUM_GENERATIONS, 159 'maximum_width' => self::MAXIMUM_WIDTH, 160 'minimum_width' => self::MINIMUM_WIDTH, 161 'module_name' => $this->name(), 162 'title' => $this->chartTitle($individual), 163 ]); 164 } 165 166 /** 167 * Generate both the HTML and PNG components of the fan chart 168 * 169 * @param Individual $individual 170 * @param int $chart_style 171 * @param int $fan_width 172 * @param int $generations 173 * @param ChartService $chart_service 174 * 175 * @return Response 176 */ 177 protected function chart(Individual $individual, int $chart_style, int $fan_width, int $generations, ChartService $chart_service): Response 178 { 179 $ancestors = $chart_service->sosaStradonitzAncestors($individual, $generations); 180 181 $gen = $generations - 1; 182 $sosa = 2 ** ($generations) - 1; 183 184 // fan size 185 $fanw = 640 * $fan_width / 100; 186 $cx = $fanw / 2 - 1; // center x 187 $cy = $cx; // center y 188 $rx = $fanw - 1; 189 $rw = $fanw / ($gen + 1); 190 $fanh = $fanw; // fan height 191 if ($chart_style === self::STYLE_HALF_CIRCLE) { 192 $fanh = $fanh * ($gen + 1) / ($gen * 2); 193 } 194 if ($chart_style === self::STYLE_THREE_QUARTER_CIRCLE) { 195 $fanh = $fanh * 0.86; 196 } 197 $scale = $fanw / 640; 198 199 // Create the image 200 $image = imagecreate((int) $fanw, (int) $fanh); 201 202 // Create colors 203 $transparent = imagecolorallocate($image, 0, 0, 0); 204 imagecolortransparent($image, $transparent); 205 206 $theme = app()->make(ModuleThemeInterface::class); 207 208 $foreground = $this->imageColor($image, $theme->parameter('chart-font-color')); 209 210 $backgrounds = [ 211 'M' => $this->imageColor($image, $theme->parameter('chart-background-m')), 212 'F' => $this->imageColor($image, $theme->parameter('chart-background-f')), 213 'U' => $this->imageColor($image, $theme->parameter('chart-background-u')), 214 ]; 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->getFullName(); 251 $addname = $person->getAddName(); 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->getSex()]; 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 WT_ROOT . '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 .= '<a href="' . e($person->url()) . '" class="name1">' . $name; 348 if ($addname) { 349 $html .= $addname; 350 } 351 $html .= '</a>'; 352 $html .= '<ul class="charts">'; 353 foreach ($theme->individualBoxMenu($person) as $menu) { 354 $html .= $menu->getMenuAsList(); 355 } 356 $html .= '</ul>'; 357 $html .= '</div></div>'; 358 $html .= '</div>'; 359 $areas .= ' alt="' . strip_tags($person->getFullName()) . '" title="' . strip_tags($person->getFullName()) . '">'; 360 } 361 $deg1 -= $angle; 362 $deg2 -= $angle; 363 $sosa--; 364 } 365 $rx -= $rw; 366 $gen--; 367 } 368 369 ob_start(); 370 imagepng($image); 371 imagedestroy($image); 372 $png = ob_get_clean(); 373 374 return new Response(view('modules/fanchart/chart', [ 375 'fanh' => $fanh, 376 'fanw' => $fanw, 377 'html' => $html, 378 'areas' => $areas, 379 'png' => $png, 380 'title' => $this->chartTitle($individual), 381 ])); 382 } 383 384 /** 385 * split and center text by lines 386 * 387 * @param string $data input string 388 * @param int $maxlen max length of each line 389 * 390 * @return string $text output string 391 */ 392 protected function splitAlignText(string $data, int $maxlen): string 393 { 394 $RTLOrd = [ 395 215, 396 216, 397 217, 398 218, 399 219, 400 ]; 401 402 $lines = explode("\n", $data); 403 // more than 1 line : recursive calls 404 if (count($lines) > 1) { 405 $text = ''; 406 foreach ($lines as $line) { 407 $text .= $this->splitAlignText($line, $maxlen) . "\n"; 408 } 409 410 return $text; 411 } 412 // process current line word by word 413 $split = explode(' ', $data); 414 $text = ''; 415 $line = ''; 416 417 // do not split hebrew line 418 $found = false; 419 foreach ($RTLOrd as $ord) { 420 if (strpos($data, chr($ord)) !== false) { 421 $found = true; 422 } 423 } 424 if ($found) { 425 $line = $data; 426 } else { 427 foreach ($split as $word) { 428 $len = strlen($line); 429 $wlen = strlen($word); 430 if (($len + $wlen) < $maxlen) { 431 if (!empty($line)) { 432 $line .= ' '; 433 } 434 $line .= "$word"; 435 } else { 436 $p = max(0, (int) (($maxlen - $len) / 2)); 437 if (!empty($line)) { 438 $line = str_repeat(' ', $p) . $line; // center alignment using spaces 439 $text .= $line . "\n"; 440 } 441 $line = $word; 442 } 443 } 444 } 445 // last line 446 if (!empty($line)) { 447 $len = strlen($line); 448 if (in_array(ord($line{0}), $RTLOrd)) { 449 $len /= 2; 450 } 451 $p = max(0, (int) (($maxlen - $len) / 2)); 452 $line = str_repeat(' ', $p) . $line; // center alignment using spaces 453 $text .= $line; 454 } 455 456 return $text; 457 } 458 459 /** 460 * Convert a CSS color into a GD color. 461 * 462 * @param resource $image 463 * @param string $css_color 464 * 465 * @return int 466 */ 467 protected function imageColor($image, string $css_color): int 468 { 469 return imagecolorallocate( 470 $image, 471 (int) hexdec(substr($css_color, 0, 2)), 472 (int) hexdec(substr($css_color, 2, 2)), 473 (int) hexdec(substr($css_color, 4, 2)) 474 ); 475 } 476 477 /** 478 * This chart can display its output in a number of styles 479 * 480 * @return array 481 */ 482 protected function chartStyles(): array 483 { 484 return [ 485 /* I18N: layout option for the fan chart */ 486 self::STYLE_HALF_CIRCLE => I18N::translate('half circle'), 487 /* I18N: layout option for the fan chart */ 488 self::STYLE_THREE_QUARTER_CIRCLE => I18N::translate('three-quarter circle'), 489 /* I18N: layout option for the fan chart */ 490 self::STYLE_FULL_CIRCLE => I18N::translate('full circle'), 491 ]; 492 } 493} 494