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 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 ChartService $chart_service 116 * 117 * @return Response 118 */ 119 public function getChartAction(Request $request, Tree $tree, ChartService $chart_service): Response 120 { 121 $ajax = (bool) $request->get('ajax'); 122 $xref = $request->get('xref', ''); 123 $individual = Individual::getInstance($xref, $tree); 124 125 Auth::checkIndividualAccess($individual); 126 127 $chart_style = (int) $request->get('chart_style', self::DEFAULT_STYLE); 128 $fan_width = (int) $request->get('fan_width', self::DEFAULT_WIDTH); 129 $generations = (int) $request->get('generations', self::DEFAULT_GENERATIONS); 130 131 $fan_width = min($fan_width, self::MAXIMUM_WIDTH); 132 $fan_width = max($fan_width, self::MINIMUM_WIDTH); 133 134 $generations = min($generations, self::MAXIMUM_GENERATIONS); 135 $generations = max($generations, self::MINIMUM_GENERATIONS); 136 137 if ($ajax) { 138 return $this->chart($individual, $chart_style, $fan_width, $generations, $chart_service); 139 } 140 141 $ajax_url = $this->chartUrl($individual, [ 142 'ajax' => true, 143 'chart_style' => $chart_style, 144 'fan_width' => $fan_width, 145 'generations' => $generations, 146 ]); 147 148 return $this->viewResponse('modules/fanchart/page', [ 149 'ajax_url' => $ajax_url, 150 'chart_style' => $chart_style, 151 'chart_styles' => $this->chartStyles(), 152 'fan_width' => $fan_width, 153 'generations' => $generations, 154 'individual' => $individual, 155 'maximum_generations' => self::MAXIMUM_GENERATIONS, 156 'minimum_generations' => self::MINIMUM_GENERATIONS, 157 'maximum_width' => self::MAXIMUM_WIDTH, 158 'minimum_width' => self::MINIMUM_WIDTH, 159 'module_name' => $this->name(), 160 'title' => $this->chartTitle($individual), 161 ]); 162 } 163 164 /** 165 * Generate both the HTML and PNG components of the fan chart 166 * 167 * @param Individual $individual 168 * @param int $chart_style 169 * @param int $fan_width 170 * @param int $generations 171 * @param ChartService $chart_service 172 * 173 * @return Response 174 */ 175 protected function chart(Individual $individual, int $chart_style, int $fan_width, int $generations, ChartService $chart_service): Response 176 { 177 $ancestors = $chart_service->sosaStradonitzAncestors($individual, $generations); 178 179 $gen = $generations - 1; 180 $sosa = 2 ** ($generations) - 1; 181 182 // fan size 183 $fanw = 640 * $fan_width / 100; 184 $cx = $fanw / 2 - 1; // center x 185 $cy = $cx; // center y 186 $rx = $fanw - 1; 187 $rw = $fanw / ($gen + 1); 188 $fanh = $fanw; // fan height 189 if ($chart_style === self::STYLE_HALF_CIRCLE) { 190 $fanh = $fanh * ($gen + 1) / ($gen * 2); 191 } 192 if ($chart_style === self::STYLE_THREE_QUARTER_CIRCLE) { 193 $fanh = $fanh * 0.86; 194 } 195 $scale = $fanw / 640; 196 197 // Create the image 198 $image = imagecreate((int) $fanw, (int) $fanh); 199 200 // Create colors 201 $transparent = imagecolorallocate($image, 0, 0, 0); 202 imagecolortransparent($image, $transparent); 203 204 $theme = Theme::theme(); 205 206 $foreground = $this->imageColor($image, $theme->parameter('chart-font-color')); 207 208 $backgrounds = [ 209 'M' => $this->imageColor($image, $theme->parameter('chart-background-m')), 210 'F' => $this->imageColor($image, $theme->parameter('chart-background-f')), 211 'U' => $this->imageColor($image, $theme->parameter('chart-background-u')), 212 ]; 213 214 215 imagefilledrectangle($image, 0, 0, (int) $fanw, (int) $fanh, $transparent); 216 217 $fandeg = 90 * $chart_style; 218 219 // Popup menus for each ancestor 220 $html = ''; 221 222 // Areas for the imagemap 223 $areas = ''; 224 225 // loop to create fan cells 226 while ($gen >= 0) { 227 // clean current generation area 228 $deg2 = 360 + ($fandeg - 180) / 2; 229 $deg1 = $deg2 - $fandeg; 230 imagefilledarc($image, (int) $cx, (int) $cy, (int) $rx, (int) $rx, (int) $deg1, (int) $deg2, $backgrounds['U'], IMG_ARC_PIE); 231 $rx -= 3; 232 233 // calculate new angle 234 $p2 = 2 ** $gen; 235 $angle = $fandeg / $p2; 236 $deg2 = 360 + ($fandeg - 180) / 2; 237 $deg1 = $deg2 - $angle; 238 // special case for rootid cell 239 if ($gen == 0) { 240 $deg1 = 90; 241 $deg2 = 360 + $deg1; 242 } 243 244 // draw each cell 245 while ($sosa >= $p2) { 246 if ($ancestors->has($sosa)) { 247 $person = $ancestors->get($sosa); 248 $name = $person->getFullName(); 249 $addname = $person->getAddName(); 250 251 $text = I18N::reverseText($name); 252 if ($addname) { 253 $text .= "\n" . I18N::reverseText($addname); 254 } 255 256 $text .= "\n" . I18N::reverseText($person->getLifeSpan()); 257 258 $background = $backgrounds[$person->getSex()]; 259 260 imagefilledarc($image, (int) $cx, (int) $cy, (int) $rx, (int) $rx, (int) $deg1, (int) $deg2, $background, IMG_ARC_PIE); 261 262 // split and center text by lines 263 $wmax = (int) ($angle * 7 / 7 * $scale); 264 $wmax = min($wmax, 35 * $scale); 265 if ($gen == 0) { 266 $wmax = min($wmax, 17 * $scale); 267 } 268 $text = $this->splitAlignText($text, (int) $wmax); 269 270 // text angle 271 $tangle = 270 - ($deg1 + $angle / 2); 272 if ($gen == 0) { 273 $tangle = 0; 274 } 275 276 // calculate text position 277 $deg = $deg1 + 0.44; 278 if ($deg2 - $deg1 > 40) { 279 $deg = $deg1 + ($deg2 - $deg1) / 11; 280 } 281 if ($deg2 - $deg1 > 80) { 282 $deg = $deg1 + ($deg2 - $deg1) / 7; 283 } 284 if ($deg2 - $deg1 > 140) { 285 $deg = $deg1 + ($deg2 - $deg1) / 4; 286 } 287 if ($gen == 0) { 288 $deg = 180; 289 } 290 $rad = deg2rad($deg); 291 $mr = ($rx - $rw / 4) / 2; 292 if ($gen > 0 && $deg2 - $deg1 > 80) { 293 $mr = $rx / 2; 294 } 295 $tx = $cx + $mr * cos($rad); 296 $ty = $cy + $mr * sin($rad); 297 if ($sosa == 1) { 298 $ty -= $mr / 2; 299 } 300 301 // print text 302 imagettftext( 303 $image, 304 7, 305 $tangle, 306 (int) $tx, 307 (int) $ty, 308 $foreground, 309 WT_ROOT . 'resources/fonts/DejaVuSans.ttf', 310 $text 311 ); 312 313 $areas .= '<area shape="poly" coords="'; 314 // plot upper points 315 $mr = $rx / 2; 316 $deg = $deg1; 317 while ($deg <= $deg2) { 318 $rad = deg2rad($deg); 319 $tx = round($cx + $mr * cos($rad)); 320 $ty = round($cy + $mr * sin($rad)); 321 $areas .= "$tx,$ty,"; 322 $deg += ($deg2 - $deg1) / 6; 323 } 324 // plot lower points 325 $mr = ($rx - $rw) / 2; 326 $deg = $deg2; 327 while ($deg >= $deg1) { 328 $rad = deg2rad($deg); 329 $tx = round($cx + $mr * cos($rad)); 330 $ty = round($cy + $mr * sin($rad)); 331 $areas .= "$tx,$ty,"; 332 $deg -= ($deg2 - $deg1) / 6; 333 } 334 // join first point 335 $mr = $rx / 2; 336 $deg = $deg1; 337 $rad = deg2rad($deg); 338 $tx = round($cx + $mr * cos($rad)); 339 $ty = round($cy + $mr * sin($rad)); 340 $areas .= "$tx,$ty"; 341 // add action url 342 $areas .= '" href="#' . $person->xref() . '"'; 343 $html .= '<div id="' . $person->xref() . '" class="fan_chart_menu">'; 344 $html .= '<div class="person_box"><div class="details1">'; 345 $html .= '<a href="' . e($person->url()) . '" class="name1">' . $name; 346 if ($addname) { 347 $html .= $addname; 348 } 349 $html .= '</a>'; 350 $html .= '<ul class="charts">'; 351 foreach ($theme->individualBoxMenu($person) as $menu) { 352 $html .= $menu->getMenuAsList(); 353 } 354 $html .= '</ul>'; 355 $html .= '</div></div>'; 356 $html .= '</div>'; 357 $areas .= ' alt="' . strip_tags($person->getFullName()) . '" title="' . strip_tags($person->getFullName()) . '">'; 358 } 359 $deg1 -= $angle; 360 $deg2 -= $angle; 361 $sosa--; 362 } 363 $rx -= $rw; 364 $gen--; 365 } 366 367 ob_start(); 368 imagepng($image); 369 imagedestroy($image); 370 $png = ob_get_clean(); 371 372 return new Response(view('modules/fanchart/chart', [ 373 'fanh' => $fanh, 374 'fanw' => $fanw, 375 'html' => $html, 376 'areas' => $areas, 377 'png' => $png, 378 'title' => $this->chartTitle($individual), 379 ])); 380 } 381 382 /** 383 * split and center text by lines 384 * 385 * @param string $data input string 386 * @param int $maxlen max length of each line 387 * 388 * @return string $text output string 389 */ 390 protected function splitAlignText(string $data, int $maxlen): string 391 { 392 $RTLOrd = [ 393 215, 394 216, 395 217, 396 218, 397 219, 398 ]; 399 400 $lines = explode("\n", $data); 401 // more than 1 line : recursive calls 402 if (count($lines) > 1) { 403 $text = ''; 404 foreach ($lines as $line) { 405 $text .= $this->splitAlignText($line, $maxlen) . "\n"; 406 } 407 408 return $text; 409 } 410 // process current line word by word 411 $split = explode(' ', $data); 412 $text = ''; 413 $line = ''; 414 415 // do not split hebrew line 416 $found = false; 417 foreach ($RTLOrd as $ord) { 418 if (strpos($data, chr($ord)) !== false) { 419 $found = true; 420 } 421 } 422 if ($found) { 423 $line = $data; 424 } else { 425 foreach ($split as $word) { 426 $len = strlen($line); 427 $wlen = strlen($word); 428 if (($len + $wlen) < $maxlen) { 429 if (!empty($line)) { 430 $line .= ' '; 431 } 432 $line .= "$word"; 433 } else { 434 $p = max(0, (int) (($maxlen - $len) / 2)); 435 if (!empty($line)) { 436 $line = str_repeat(' ', $p) . $line; // center alignment using spaces 437 $text .= $line . "\n"; 438 } 439 $line = $word; 440 } 441 } 442 } 443 // last line 444 if (!empty($line)) { 445 $len = strlen($line); 446 if (in_array(ord($line{0}), $RTLOrd)) { 447 $len /= 2; 448 } 449 $p = max(0, (int) (($maxlen - $len) / 2)); 450 $line = str_repeat(' ', $p) . $line; // center alignment using spaces 451 $text .= $line; 452 } 453 454 return $text; 455 } 456 457 /** 458 * Convert a CSS color into a GD color. 459 * 460 * @param resource $image 461 * @param string $css_color 462 * 463 * @return int 464 */ 465 protected function imageColor($image, string $css_color): int 466 { 467 return imagecolorallocate( 468 $image, 469 (int) hexdec(substr($css_color, 0, 2)), 470 (int) hexdec(substr($css_color, 2, 2)), 471 (int) hexdec(substr($css_color, 4, 2)) 472 ); 473 } 474 475 /** 476 * This chart can display its output in a number of styles 477 * 478 * @return array 479 */ 480 protected function chartStyles(): array 481 { 482 return [ 483 /* I18N: layout option for the fan chart */ 484 self::STYLE_HALF_CIRCLE => I18N::translate('half circle'), 485 /* I18N: layout option for the fan chart */ 486 self::STYLE_THREE_QUARTER_CIRCLE => I18N::translate('three-quarter circle'), 487 /* I18N: layout option for the fan chart */ 488 self::STYLE_FULL_CIRCLE => I18N::translate('full circle'), 489 ]; 490 } 491} 492