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 identified in the control panel, 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 *= 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(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 .= '<div class="charts">'; 347 $html .= '<a href="' . e($person->url()) . '" class="dropdown-item">' . $name. '</a>'; 348 foreach ($theme->individualBoxMenu($person) as $menu) { 349 $html .= '<a href="' . e($menu->getLink()) . '" class="dropdown-item p-1 ' . e($menu->getClass()) . '">' . $menu->getLabel() . '</a>'; 350 } 351 $html .= '</div>'; 352 $html .= '</div></div>'; 353 $html .= '</div>'; 354 $areas .= ' alt="' . strip_tags($person->fullName()) . '" title="' . strip_tags($person->fullName()) . '">'; 355 } 356 $deg1 -= $angle; 357 $deg2 -= $angle; 358 $sosa--; 359 } 360 $rx -= $rw; 361 $gen--; 362 } 363 364 ob_start(); 365 imagepng($image); 366 imagedestroy($image); 367 $png = ob_get_clean(); 368 369 return new Response(view('modules/fanchart/chart', [ 370 'fanh' => $fanh, 371 'fanw' => $fanw, 372 'html' => $html, 373 'areas' => $areas, 374 'png' => $png, 375 'title' => $this->chartTitle($individual), 376 ])); 377 } 378 379 /** 380 * split and center text by lines 381 * 382 * @param string $data input string 383 * @param int $maxlen max length of each line 384 * 385 * @return string $text output string 386 */ 387 protected function splitAlignText(string $data, int $maxlen): string 388 { 389 $RTLOrd = [ 390 215, 391 216, 392 217, 393 218, 394 219, 395 ]; 396 397 $lines = explode("\n", $data); 398 // more than 1 line : recursive calls 399 if (count($lines) > 1) { 400 $text = ''; 401 foreach ($lines as $line) { 402 $text .= $this->splitAlignText($line, $maxlen) . "\n"; 403 } 404 405 return $text; 406 } 407 // process current line word by word 408 $split = explode(' ', $data); 409 $text = ''; 410 $line = ''; 411 412 // do not split hebrew line 413 $found = false; 414 foreach ($RTLOrd as $ord) { 415 if (strpos($data, chr($ord)) !== false) { 416 $found = true; 417 } 418 } 419 if ($found) { 420 $line = $data; 421 } else { 422 foreach ($split as $word) { 423 $len = strlen($line); 424 $wlen = strlen($word); 425 if (($len + $wlen) < $maxlen) { 426 if (!empty($line)) { 427 $line .= ' '; 428 } 429 $line .= $word; 430 } else { 431 $p = max(0, (int) (($maxlen - $len) / 2)); 432 if (!empty($line)) { 433 $line = str_repeat(' ', $p) . $line; // center alignment using spaces 434 $text .= $line . "\n"; 435 } 436 $line = $word; 437 } 438 } 439 } 440 // last line 441 if (!empty($line)) { 442 $len = strlen($line); 443 if (in_array(ord($line{0}), $RTLOrd)) { 444 $len /= 2; 445 } 446 $p = max(0, (int) (($maxlen - $len) / 2)); 447 $line = str_repeat(' ', $p) . $line; // center alignment using spaces 448 $text .= $line; 449 } 450 451 return $text; 452 } 453 454 /** 455 * Convert a CSS color into a GD color. 456 * 457 * @param resource $image 458 * @param string $css_color 459 * 460 * @return int 461 */ 462 protected function imageColor($image, string $css_color): int 463 { 464 return imagecolorallocate( 465 $image, 466 (int) hexdec(substr($css_color, 0, 2)), 467 (int) hexdec(substr($css_color, 2, 2)), 468 (int) hexdec(substr($css_color, 4, 2)) 469 ); 470 } 471 472 /** 473 * This chart can display its output in a number of styles 474 * 475 * @return array 476 */ 477 protected function chartStyles(): array 478 { 479 return [ 480 /* I18N: layout option for the fan chart */ 481 self::STYLE_HALF_CIRCLE => I18N::translate('half circle'), 482 /* I18N: layout option for the fan chart */ 483 self::STYLE_THREE_QUARTER_CIRCLE => I18N::translate('three-quarter circle'), 484 /* I18N: layout option for the fan chart */ 485 self::STYLE_FULL_CIRCLE => I18N::translate('full circle'), 486 ]; 487 } 488} 489