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\Functions\FunctionsEdit; 22use Fisharebest\Webtrees\I18N; 23use Fisharebest\Webtrees\Individual; 24use Fisharebest\Webtrees\Menu; 25use Fisharebest\Webtrees\Services\ChartService; 26use Fisharebest\Webtrees\Theme; 27use Fisharebest\Webtrees\Tree; 28use Fisharebest\Webtrees\User; 29use stdClass; 30use Symfony\Component\HttpFoundation\Request; 31use Symfony\Component\HttpFoundation\Response; 32 33/** 34 * Class PedigreeChartModule 35 */ 36class PedigreeChartModule extends AbstractModule implements ModuleChartInterface 37{ 38 use ModuleChartTrait; 39 40 // With more than 8 generations, we run out of pixels on the <canvas> 41 protected const MAX_GENERATIONS = 8; 42 protected const MIN_GENERATIONS = 2; 43 44 protected const DEFAULT_GENERATIONS = '4'; 45 46 /** 47 * Chart orientation codes 48 * Dont change them! the offset calculations rely on this order 49 */ 50 public const PORTRAIT = 0; 51 public const LANDSCAPE = 1; 52 public const OLDEST_AT_TOP = 2; 53 public const OLDEST_AT_BOTTOM = 3; 54 55 protected const DEFAULT_ORIENTATION = self::LANDSCAPE; 56 57 /** @var int Number of generation to display */ 58 protected $generations; 59 60 /** @var array data pertaining to each chart node */ 61 protected $nodes = []; 62 63 /** @var int Number of nodes in the chart */ 64 protected $treesize; 65 66 /** @var stdClass Determine which arrows to use for each of the chart orientations */ 67 protected $arrows; 68 69 /** @var Individual */ 70 protected $root; 71 72 /** 73 * Next and previous generation arrow size in pixels. 74 */ 75 protected const ARROW_SIZE = 22; 76 77 /** 78 * How should this module be labelled on tabs, menus, etc.? 79 * 80 * @return string 81 */ 82 public function title(): string 83 { 84 /* I18N: Name of a module/chart */ 85 return I18N::translate('Pedigree'); 86 } 87 88 /** 89 * A sentence describing what this module does. 90 * 91 * @return string 92 */ 93 public function description(): string 94 { 95 /* I18N: Description of the “PedigreeChart” module */ 96 return I18N::translate('A chart of an individual’s ancestors, formatted as a tree.'); 97 } 98 99 /** 100 * CSS class for the URL. 101 * 102 * @return string 103 */ 104 public function chartMenuClass(): string 105 { 106 return 'menu-chart-pedigree'; 107 } 108 109 /** 110 * Return a menu item for this chart - for use in individual boxes. 111 * 112 * @param Individual $individual 113 * 114 * @return Menu|null 115 */ 116 public function chartBoxMenu(Individual $individual): ?Menu 117 { 118 return $this->chartMenu($individual); 119 } 120 121 /** 122 * The title for a specific instance of this chart. 123 * 124 * @param Individual $individual 125 * 126 * @return string 127 */ 128 public function chartTitle(Individual $individual): string 129 { 130 /* I18N: %s is an individual’s name */ 131 return I18N::translate('Pedigree tree of %s', $individual->getFullName()); 132 } 133 134 /** 135 * A form to request the chart parameters. 136 * 137 * @param Request $request 138 * @param Tree $tree 139 * @param User $user 140 * @param ChartService $chart_service 141 * 142 * @return Response 143 */ 144 public function getChartAction(Request $request, Tree $tree, User $user, ChartService $chart_service): Response 145 { 146 $ajax = (bool) $request->get('ajax'); 147 $xref = $request->get('xref', ''); 148 $individual = Individual::getInstance($xref, $tree); 149 150 Auth::checkIndividualAccess($individual); 151 Auth::checkComponentAccess($this, 'chart', $tree, $user); 152 153 $orientation = (int) $request->get('orientation', static::DEFAULT_ORIENTATION); 154 $generations = (int) $request->get('generations', static::DEFAULT_GENERATIONS); 155 156 $generations = min(static::MAX_GENERATIONS, $generations); 157 $generations = max(static::MIN_GENERATIONS, $generations); 158 159 $generation_options = $this->generationOptions(); 160 161 if ($ajax) { 162 return $this->chart($individual, $generations, $orientation, $chart_service); 163 } 164 165 $ajax_url = $this->chartUrl($individual, [ 166 'ajax' => true, 167 'generations' => $generations, 168 'orientation' => $orientation, 169 ]); 170 171 return $this->viewResponse('modules/pedigree-chart/page', [ 172 'ajax_url' => $ajax_url, 173 'generations' => $generations, 174 'generation_options' => $generation_options, 175 'individual' => $individual, 176 'module_name' => $this->name(), 177 'orientation' => $orientation, 178 'orientations' => $this->orientations(), 179 'title' => $this->chartTitle($individual), 180 ]); 181 } 182 183 /** 184 * @param Individual $individual 185 * @param int $generations 186 * @param int $orientation 187 * @param ChartService $chart_service 188 * 189 * @return Response 190 */ 191 public function chart(Individual $individual, int $generations, int $orientation, ChartService $chart_service): Response 192 { 193 $bxspacing = Theme::theme()->parameter('chart-spacing-x'); 194 $byspacing = Theme::theme()->parameter('chart-spacing-y'); 195 $curgen = 1; // Track which generation the algorithm is currently working on 196 $addoffset = []; 197 198 $this->root = $individual; 199 200 $this->treesize = (2 ** $generations) - 1; 201 202 $this->nodes = []; 203 204 $ancestors = $chart_service->sosaStradonitzAncestors($individual, $generations); 205 206 // $ancestors starts array at index 1 we need to start at 0 207 for ($i = 0; $i < $this->treesize; ++$i) { 208 $this->nodes[$i] = [ 209 'indi' => $ancestors->get($i + 1), 210 'x' => 0, 211 'y' => 0, 212 ]; 213 } 214 215 // Are there ancestors beyond the bounds of this chart 216 $chart_has_ancestors = false; 217 218 // Check earliest generation for any ancestors 219 for ($i = (int) ($this->treesize / 2); $i < $this->treesize; $i++) { 220 $chart_has_ancestors = $chart_has_ancestors || ($this->nodes[$i]['indi'] && $this->nodes[$i]['indi']->getChildFamilies()); 221 } 222 223 $this->arrows = new stdClass(); 224 switch ($orientation) { 225 default: 226 case static::PORTRAIT: 227 case static::LANDSCAPE: 228 $this->arrows->prevGen = 'fas fa-arrow-end wt-icon-arrow-end'; 229 $this->arrows->menu = 'fas fa-arrow-start wt-icon-arrow-start'; 230 $addoffset['x'] = $chart_has_ancestors ? static::ARROW_SIZE : 0; 231 $addoffset['y'] = 0; 232 break; 233 234 case static::OLDEST_AT_TOP: 235 $this->arrows->prevGen = 'fas fa-arrow-up wt-icon-arrow-up'; 236 $this->arrows->menu = 'fas fa-arrow-down wt-icon-arrow-down'; 237 $addoffset['x'] = 0; 238 $addoffset['y'] = $this->root->getSpouseFamilies() ? static::ARROW_SIZE : 0; 239 break; 240 241 case static::OLDEST_AT_BOTTOM: 242 $this->arrows->prevGen = 'fas fa-arrow-down wt-icon-arrow-down'; 243 $this->arrows->menu = 'fas fa-arrow-up wt-icon-arrow-up'; 244 $addoffset['x'] = 0; 245 $addoffset['y'] = $chart_has_ancestors ? static::ARROW_SIZE : 0; 246 break; 247 } 248 249 // Create and position the DIV layers for the pedigree tree 250 for ($i = ($this->treesize - 1); $i >= 0; $i--) { 251 // Check to see if we have moved to the next generation 252 if ($i < (int) ($this->treesize / (2 ** $curgen))) { 253 $curgen++; 254 } 255 256 // Box position in current generation 257 $boxpos = $i - (2 ** ($this->generations - $curgen)); 258 // Offset multiple for current generation 259 if ($orientation < static::OLDEST_AT_TOP) { 260 $genoffset = 2 ** ($curgen - $orientation); 261 $boxspacing = Theme::theme()->parameter('chart-box-y') + $byspacing; 262 } else { 263 $genoffset = 2 ** ($curgen - 1); 264 $boxspacing = Theme::theme()->parameter('chart-box-x') + $byspacing; 265 } 266 // Calculate the yoffset position in the generation put child between parents 267 $yoffset = ($boxpos * ($boxspacing * $genoffset)) + (($boxspacing / 2) * $genoffset) + ($boxspacing * $genoffset); 268 269 // Calculate the xoffset 270 switch ($orientation) { 271 default: 272 case static::PORTRAIT: 273 $xoffset = ($this->generations - $curgen) * ((Theme::theme()->parameter('chart-box-x') + $bxspacing) / 1.8); 274 if (!$i && $this->root->getSpouseFamilies()) { 275 $xoffset -= static::ARROW_SIZE; 276 } 277 // Compact the tree 278 if ($curgen < $this->generations) { 279 if ($i % 2 == 0) { 280 $yoffset = $yoffset - (($boxspacing / 2) * ($curgen - 1)); 281 } else { 282 $yoffset = $yoffset + (($boxspacing / 2) * ($curgen - 1)); 283 } 284 $parent = (int) (($i - 1) / 2); 285 $pgen = $curgen; 286 while ($parent > 0) { 287 if ($parent % 2 == 0) { 288 $yoffset = $yoffset - (($boxspacing / 2) * $pgen); 289 } else { 290 $yoffset = $yoffset + (($boxspacing / 2) * $pgen); 291 } 292 $pgen++; 293 if ($pgen > 3) { 294 $temp = 0; 295 for ($j = 1; $j < ($pgen - 2); $j++) { 296 $temp += ((2 ** $j) - 1); 297 } 298 if ($parent % 2 == 0) { 299 $yoffset = $yoffset - (($boxspacing / 2) * $temp); 300 } else { 301 $yoffset = $yoffset + (($boxspacing / 2) * $temp); 302 } 303 } 304 $parent = (int) (($parent - 1) / 2); 305 } 306 if ($curgen > 3) { 307 $temp = 0; 308 for ($j = 1; $j < ($curgen - 2); $j++) { 309 $temp += ((2 ** $j) - 1); 310 } 311 if ($i % 2 == 0) { 312 $yoffset = $yoffset - (($boxspacing / 2) * $temp); 313 } else { 314 $yoffset = $yoffset + (($boxspacing / 2) * $temp); 315 } 316 } 317 } 318 $yoffset -= (($boxspacing / 2) * (2 ** ($this->generations - 2)) - ($boxspacing / 2)); 319 break; 320 321 case static::LANDSCAPE: 322 $xoffset = ($this->generations - $curgen) * (Theme::theme()->parameter('chart-box-x') + $bxspacing); 323 if ($curgen == 1) { 324 $xoffset += 10; 325 } 326 break; 327 328 case static::OLDEST_AT_TOP: 329 // Swap x & y offsets as chart is rotated 330 $xoffset = $yoffset; 331 $yoffset = $curgen * (Theme::theme()->parameter('chart-box-y') + ($byspacing * 4)); 332 break; 333 334 case static::OLDEST_AT_BOTTOM: 335 // Swap x & y offsets as chart is rotated 336 $xoffset = $yoffset; 337 $yoffset = ($this->generations - $curgen) * (Theme::theme()->parameter('chart-box-y') + ($byspacing * 2)); 338 if ($i && $this->root->getSpouseFamilies()) { 339 $yoffset += static::ARROW_SIZE; 340 } 341 break; 342 } 343 $this->nodes[$i]['x'] = (int) $xoffset; 344 $this->nodes[$i]['y'] = (int) $yoffset; 345 } 346 347 // Find the minimum x & y offsets and deduct that number from 348 // each value in the array so that offsets start from zero 349 $min_xoffset = min(array_map(function (array $item): int { 350 return $item['x']; 351 }, $this->nodes)); 352 $min_yoffset = min(array_map(function (array $item): int { 353 return $item['y']; 354 }, $this->nodes)); 355 356 array_walk($this->nodes, function (&$item) use ($min_xoffset, $min_yoffset) { 357 $item['x'] -= $min_xoffset; 358 $item['y'] -= $min_yoffset; 359 }); 360 361 // Calculate chart & canvas dimensions 362 $max_xoffset = max(array_map(function ($item) { 363 return $item['x']; 364 }, $this->nodes)); 365 $max_yoffset = max(array_map(function ($item) { 366 return $item['y']; 367 }, $this->nodes)); 368 369 $canvas_width = $max_xoffset + $bxspacing + Theme::theme()->parameter('chart-box-x') + $addoffset['x']; 370 $canvas_height = $max_yoffset + $byspacing + Theme::theme()->parameter('chart-box-y') + $addoffset['y']; 371 $posn = I18N::direction() === 'rtl' ? 'right' : 'left'; 372 $last_gen_start = (int) floor($this->treesize / 2); 373 if ($orientation === static::OLDEST_AT_TOP || $orientation === static::OLDEST_AT_BOTTOM) { 374 $flex_direction = ' flex-column'; 375 } else { 376 $flex_direction = ''; 377 } 378 379 foreach ($this->nodes as $n => $node) { 380 if ($n >= $last_gen_start) { 381 $this->nodes[$n]['previous_gen'] = $this->gotoPreviousGen($n, $generations, $orientation, $chart_has_ancestors); 382 } else { 383 $this->nodes[$n]['previous_gen'] = ''; 384 } 385 } 386 387 $html = view('modules/pedigree-chart/chart', [ 388 'canvas_height' => $canvas_height, 389 'canvas_width' => $canvas_width, 390 'child_menu' => $this->getMenu($individual, $generations, $orientation), 391 'flex_direction' => $flex_direction, 392 'last_gen_start' => $last_gen_start, 393 'orientation' => $orientation, 394 'nodes' => $this->nodes, 395 'landscape' => static::LANDSCAPE, 396 'oldest_at_top' => static::OLDEST_AT_TOP, 397 'oldest_at_bottom' => static::OLDEST_AT_BOTTOM, 398 'portrait' => static::PORTRAIT, 399 'posn' => $posn, 400 ]); 401 402 return new Response($html); 403 } 404 405 /** 406 * Build a menu for the chart root individual 407 * 408 * @param Individual $root 409 * @param int $generations 410 * @param int $orientation 411 * 412 * @return string 413 */ 414 public function getMenu(Individual $root, int $generations, int $orientation): string 415 { 416 $families = $root->getSpouseFamilies(); 417 $html = ''; 418 if (!empty($families)) { 419 $html = sprintf('<div id="childarrow"><a href="#" class="menuselect %s"></a><div id="childbox-pedigree">', $this->arrows->menu); 420 421 foreach ($families as $family) { 422 $html .= '<span class="name1">' . I18N::translate('Family') . '</span>'; 423 $spouse = $family->getSpouse($root); 424 if ($spouse) { 425 $html .= '<a class="name1" href="' . e($this->chartUrl($spouse, ['generations' => $generations, 'orientation' => $orientation])) . '">' . $spouse->getFullName() . '</a>'; 426 } 427 $children = $family->getChildren(); 428 foreach ($children as $sibling) { 429 $html .= '<a class="name1" href="' . e($this->chartUrl($sibling, ['generations' => $generations, 'orientation' => $orientation])) . '">' . $sibling->getFullName() . '</a>'; 430 } 431 } 432 433 foreach ($root->getChildFamilies() as $family) { 434 $siblings = array_filter($family->getChildren(), function (Individual $item) use ($root): bool { 435 return $root->xref() !== $item->xref(); 436 }); 437 if (!empty($siblings)) { 438 $html .= '<span class="name1">'; 439 $html .= count($siblings) > 1 ? I18N::translate('Siblings') : I18N::translate('Sibling'); 440 $html .= '</span>'; 441 foreach ($siblings as $sibling) { 442 $html .= '<a class="name1" href="' . e($this->chartUrl($sibling, ['generations' => $generations, 'orientation' => $orientation])) . '">' . $sibling->getFullName() . '</a>'; 443 } 444 } 445 } 446 $html .= '</div></div>'; 447 } 448 449 return $html; 450 } 451 452 /** 453 * Function gotoPreviousGen 454 * Create a link to generate a new chart based on the correct parent of the individual with this index 455 * 456 * @param int $index 457 * @param int $generations 458 * @param int $orientation 459 * @param bool $chart_has_ancestors 460 * 461 * @return string 462 */ 463 public function gotoPreviousGen(int $index, int $generations, int $orientation, bool $chart_has_ancestors): string 464 { 465 $html = ''; 466 if ($chart_has_ancestors) { 467 if ($this->nodes[$index]['indi'] && $this->nodes[$index]['indi']->getChildFamilies()) { 468 $html .= '<div class="ancestorarrow">'; 469 $rootParentId = 1; 470 if ($index > (int) ($this->treesize / 2) + (int) ($this->treesize / 4)) { 471 $rootParentId++; 472 } 473 $html .= '<a class="' . $this->arrows->prevGen . '" href="' . e($this->chartUrl($this->nodes[$rootParentId]['indi'], ['generations' => $generations, 'orientation' => $orientation])) . '"></a>'; 474 $html .= '</div>'; 475 } else { 476 $html .= '<div class="spacer"></div>'; 477 } 478 } 479 480 return $html; 481 } 482 483 /** 484 * @return string[] 485 */ 486 protected function generationOptions(): array 487 { 488 return FunctionsEdit::numericOptions(range(static::MIN_GENERATIONS, static::MAX_GENERATIONS)); 489 } 490 491 /** 492 * @return string[] 493 */ 494 protected function orientations(): array 495 { 496 return [ 497 0 => I18N::translate('Portrait'), 498 1 => I18N::translate('Landscape'), 499 2 => I18N::translate('Oldest at top'), 500 3 => I18N::translate('Oldest at bottom'), 501 ]; 502 } 503} 504