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