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