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\Functions\FunctionsEdit; 23use Fisharebest\Webtrees\I18N; 24use Fisharebest\Webtrees\Individual; 25use Fisharebest\Webtrees\Menu; 26use Fisharebest\Webtrees\Services\ChartService; 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 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 public const PORTRAIT = 0; 50 public const LANDSCAPE = 1; 51 public const OLDEST_AT_TOP = 2; 52 public 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 UserInterface $user 139 * @param ChartService $chart_service 140 * 141 * @return Response 142 */ 143 public function getChartAction(Request $request, Tree $tree, UserInterface $user, ChartService $chart_service): Response 144 { 145 $ajax = (bool) $request->get('ajax'); 146 $xref = $request->get('xref', ''); 147 $individual = Individual::getInstance($xref, $tree); 148 149 Auth::checkIndividualAccess($individual); 150 Auth::checkComponentAccess($this, 'chart', $tree, $user); 151 152 $orientation = (int) $request->get('orientation', static::DEFAULT_ORIENTATION); 153 $generations = (int) $request->get('generations', static::DEFAULT_GENERATIONS); 154 155 $generations = min(static::MAX_GENERATIONS, $generations); 156 $generations = max(static::MIN_GENERATIONS, $generations); 157 158 $generation_options = $this->generationOptions(); 159 160 if ($ajax) { 161 return $this->chart($individual, $generations, $orientation, $chart_service); 162 } 163 164 $ajax_url = $this->chartUrl($individual, [ 165 'ajax' => true, 166 'generations' => $generations, 167 'orientation' => $orientation, 168 ]); 169 170 return $this->viewResponse('modules/pedigree-chart/page', [ 171 'ajax_url' => $ajax_url, 172 'generations' => $generations, 173 'generation_options' => $generation_options, 174 'individual' => $individual, 175 'module_name' => $this->name(), 176 'orientation' => $orientation, 177 'orientations' => $this->orientations(), 178 'title' => $this->chartTitle($individual), 179 ]); 180 } 181 182 /** 183 * @param Individual $individual 184 * @param int $generations 185 * @param int $orientation 186 * @param ChartService $chart_service 187 * 188 * @return Response 189 */ 190 public function chart(Individual $individual, int $generations, int $orientation, ChartService $chart_service): Response 191 { 192 $bxspacing = app()->make(ModuleThemeInterface::class)->parameter('chart-spacing-x'); 193 $byspacing = app()->make(ModuleThemeInterface::class)->parameter('chart-spacing-y'); 194 $curgen = 1; // Track which generation the algorithm is currently working on 195 $addoffset = []; 196 197 $this->root = $individual; 198 199 $this->treesize = (2 ** $generations) - 1; 200 201 $this->nodes = []; 202 203 $ancestors = $chart_service->sosaStradonitzAncestors($individual, $generations); 204 205 // $ancestors starts array at index 1 we need to start at 0 206 for ($i = 0; $i < $this->treesize; ++$i) { 207 $this->nodes[$i] = [ 208 'indi' => $ancestors->get($i + 1), 209 'x' => 0, 210 'y' => 0, 211 ]; 212 } 213 214 // Are there ancestors beyond the bounds of this chart 215 $chart_has_ancestors = false; 216 217 // Check earliest generation for any ancestors 218 for ($i = (int) ($this->treesize / 2); $i < $this->treesize; $i++) { 219 $chart_has_ancestors = $chart_has_ancestors || ($this->nodes[$i]['indi'] && $this->nodes[$i]['indi']->getChildFamilies()); 220 } 221 222 $this->arrows = new stdClass(); 223 switch ($orientation) { 224 default: 225 case static::PORTRAIT: 226 case static::LANDSCAPE: 227 $this->arrows->prevGen = view('icons/arrow-right'); 228 $this->arrows->menu = view('icons/arrow-left'); 229 $addoffset['x'] = $chart_has_ancestors ? static::ARROW_SIZE : 0; 230 $addoffset['y'] = 0; 231 break; 232 233 case static::OLDEST_AT_TOP: 234 $this->arrows->prevGen = view('icons/arrow-up'); 235 $this->arrows->menu = view('icons/arrow-down'); 236 $addoffset['x'] = 0; 237 $addoffset['y'] = $this->root->getSpouseFamilies() ? static::ARROW_SIZE : 0; 238 break; 239 240 case static::OLDEST_AT_BOTTOM: 241 $this->arrows->prevGen = view('icons/arrow-down'); 242 $this->arrows->menu = view('icons/arrow-up'); 243 $addoffset['x'] = 0; 244 $addoffset['y'] = $chart_has_ancestors ? static::ARROW_SIZE : 0; 245 break; 246 } 247 248 // Create and position the DIV layers for the pedigree tree 249 for ($i = ($this->treesize - 1); $i >= 0; $i--) { 250 // Check to see if we have moved to the next generation 251 if ($i < (int) ($this->treesize / (2 ** $curgen))) { 252 $curgen++; 253 } 254 255 // Box position in current generation 256 $boxpos = $i - (2 ** ($this->generations - $curgen)); 257 // Offset multiple for current generation 258 if ($orientation < static::OLDEST_AT_TOP) { 259 $genoffset = 2 ** ($curgen - $orientation); 260 $boxspacing = app()->make(ModuleThemeInterface::class)->parameter('chart-box-y') + $byspacing; 261 } else { 262 $genoffset = 2 ** ($curgen - 1); 263 $boxspacing = app()->make(ModuleThemeInterface::class)->parameter('chart-box-x') + $byspacing; 264 } 265 // Calculate the yoffset position in the generation put child between parents 266 $yoffset = ($boxpos * ($boxspacing * $genoffset)) + (($boxspacing / 2) * $genoffset) + ($boxspacing * $genoffset); 267 268 // Calculate the xoffset 269 switch ($orientation) { 270 default: 271 case static::PORTRAIT: 272 $xoffset = ($this->generations - $curgen) * ((app()->make(ModuleThemeInterface::class)->parameter('chart-box-x') + $bxspacing) / 1.8); 273 if (!$i && $this->root->getSpouseFamilies()) { 274 $xoffset -= static::ARROW_SIZE; 275 } 276 // Compact the tree 277 if ($curgen < $this->generations) { 278 if ($i % 2 == 0) { 279 $yoffset = $yoffset - (($boxspacing / 2) * ($curgen - 1)); 280 } else { 281 $yoffset = $yoffset + (($boxspacing / 2) * ($curgen - 1)); 282 } 283 $parent = (int) (($i - 1) / 2); 284 $pgen = $curgen; 285 while ($parent > 0) { 286 if ($parent % 2 == 0) { 287 $yoffset = $yoffset - (($boxspacing / 2) * $pgen); 288 } else { 289 $yoffset = $yoffset + (($boxspacing / 2) * $pgen); 290 } 291 $pgen++; 292 if ($pgen > 3) { 293 $temp = 0; 294 for ($j = 1; $j < ($pgen - 2); $j++) { 295 $temp += ((2 ** $j) - 1); 296 } 297 if ($parent % 2 == 0) { 298 $yoffset = $yoffset - (($boxspacing / 2) * $temp); 299 } else { 300 $yoffset = $yoffset + (($boxspacing / 2) * $temp); 301 } 302 } 303 $parent = (int) (($parent - 1) / 2); 304 } 305 if ($curgen > 3) { 306 $temp = 0; 307 for ($j = 1; $j < ($curgen - 2); $j++) { 308 $temp += ((2 ** $j) - 1); 309 } 310 if ($i % 2 == 0) { 311 $yoffset = $yoffset - (($boxspacing / 2) * $temp); 312 } else { 313 $yoffset = $yoffset + (($boxspacing / 2) * $temp); 314 } 315 } 316 } 317 $yoffset -= (($boxspacing / 2) * (2 ** ($this->generations - 2)) - ($boxspacing / 2)); 318 break; 319 320 case static::LANDSCAPE: 321 $xoffset = ($this->generations - $curgen) * (app()->make(ModuleThemeInterface::class)->parameter('chart-box-x') + $bxspacing); 322 if ($curgen == 1) { 323 $xoffset += 10; 324 } 325 break; 326 327 case static::OLDEST_AT_TOP: 328 // Swap x & y offsets as chart is rotated 329 $xoffset = $yoffset; 330 $yoffset = $curgen * (app()->make(ModuleThemeInterface::class)->parameter('chart-box-y') + ($byspacing * 4)); 331 break; 332 333 case static::OLDEST_AT_BOTTOM: 334 // Swap x & y offsets as chart is rotated 335 $xoffset = $yoffset; 336 $yoffset = ($this->generations - $curgen) * (app()->make(ModuleThemeInterface::class)->parameter('chart-box-y') + ($byspacing * 2)); 337 if ($i && $this->root->getSpouseFamilies()) { 338 $yoffset += static::ARROW_SIZE; 339 } 340 break; 341 } 342 $this->nodes[$i]['x'] = (int) $xoffset; 343 $this->nodes[$i]['y'] = (int) $yoffset; 344 } 345 346 // Find the minimum x & y offsets and deduct that number from 347 // each value in the array so that offsets start from zero 348 $min_xoffset = min(array_map(function (array $item): int { 349 return $item['x']; 350 }, $this->nodes)); 351 $min_yoffset = min(array_map(function (array $item): int { 352 return $item['y']; 353 }, $this->nodes)); 354 355 array_walk($this->nodes, function (&$item) use ($min_xoffset, $min_yoffset) { 356 $item['x'] -= $min_xoffset; 357 $item['y'] -= $min_yoffset; 358 }); 359 360 // Calculate chart & canvas dimensions 361 $max_xoffset = max(array_map(function ($item) { 362 return $item['x']; 363 }, $this->nodes)); 364 $max_yoffset = max(array_map(function ($item) { 365 return $item['y']; 366 }, $this->nodes)); 367 368 $canvas_width = $max_xoffset + $bxspacing + app()->make(ModuleThemeInterface::class)->parameter('chart-box-x') + $addoffset['x']; 369 $canvas_height = $max_yoffset + $byspacing + app()->make(ModuleThemeInterface::class)->parameter('chart-box-y') + $addoffset['y']; 370 $posn = I18N::direction() === 'rtl' ? 'right' : 'left'; 371 $last_gen_start = (int) floor($this->treesize / 2); 372 if ($orientation === static::OLDEST_AT_TOP || $orientation === static::OLDEST_AT_BOTTOM) { 373 $flex_direction = ' flex-column'; 374 } else { 375 $flex_direction = ''; 376 } 377 378 foreach ($this->nodes as $n => $node) { 379 if ($n >= $last_gen_start) { 380 $this->nodes[$n]['previous_gen'] = $this->gotoPreviousGen($n, $generations, $orientation, $chart_has_ancestors); 381 } else { 382 $this->nodes[$n]['previous_gen'] = ''; 383 } 384 } 385 386 $html = view('modules/pedigree-chart/chart', [ 387 'canvas_height' => $canvas_height, 388 'canvas_width' => $canvas_width, 389 'child_menu' => $this->getMenu($individual, $generations, $orientation), 390 'flex_direction' => $flex_direction, 391 'last_gen_start' => $last_gen_start, 392 'orientation' => $orientation, 393 'nodes' => $this->nodes, 394 'landscape' => static::LANDSCAPE, 395 'oldest_at_top' => static::OLDEST_AT_TOP, 396 'oldest_at_bottom' => static::OLDEST_AT_BOTTOM, 397 'portrait' => static::PORTRAIT, 398 'posn' => $posn, 399 ]); 400 401 return new Response($html); 402 } 403 404 /** 405 * Build a menu for the chart root individual 406 * 407 * @param Individual $root 408 * @param int $generations 409 * @param int $orientation 410 * 411 * @return string 412 */ 413 public function getMenu(Individual $root, int $generations, int $orientation): string 414 { 415 $families = $root->getSpouseFamilies(); 416 $html = ''; 417 if (!empty($families)) { 418 $html = '<div id="childarrow"><a href="#" class="menuselect">' . $this->arrows->menu . '</a><div id="childbox-pedigree">'; 419 420 foreach ($families as $family) { 421 $html .= '<span class="name1">' . I18N::translate('Family') . '</span>'; 422 $spouse = $family->getSpouse($root); 423 if ($spouse) { 424 $html .= '<a class="name1" href="' . e($this->chartUrl($spouse, ['generations' => $generations, 'orientation' => $orientation])) . '">' . $spouse->getFullName() . '</a>'; 425 } 426 $children = $family->getChildren(); 427 foreach ($children as $sibling) { 428 $html .= '<a class="name1" href="' . e($this->chartUrl($sibling, ['generations' => $generations, 'orientation' => $orientation])) . '">' . $sibling->getFullName() . '</a>'; 429 } 430 } 431 432 foreach ($root->getChildFamilies() as $family) { 433 $siblings = array_filter($family->getChildren(), function (Individual $item) use ($root): bool { 434 return $root->xref() !== $item->xref(); 435 }); 436 if (!empty($siblings)) { 437 $html .= '<span class="name1">'; 438 $html .= count($siblings) > 1 ? I18N::translate('Siblings') : I18N::translate('Sibling'); 439 $html .= '</span>'; 440 foreach ($siblings as $sibling) { 441 $html .= '<a class="name1" href="' . e($this->chartUrl($sibling, ['generations' => $generations, 'orientation' => $orientation])) . '">' . $sibling->getFullName() . '</a>'; 442 } 443 } 444 } 445 $html .= '</div></div>'; 446 } 447 448 return $html; 449 } 450 451 /** 452 * Function gotoPreviousGen 453 * Create a link to generate a new chart based on the correct parent of the individual with this index 454 * 455 * @param int $index 456 * @param int $generations 457 * @param int $orientation 458 * @param bool $chart_has_ancestors 459 * 460 * @return string 461 */ 462 public function gotoPreviousGen(int $index, int $generations, int $orientation, bool $chart_has_ancestors): string 463 { 464 $html = ''; 465 if ($chart_has_ancestors) { 466 if ($this->nodes[$index]['indi'] && $this->nodes[$index]['indi']->getChildFamilies()) { 467 $html .= '<div class="ancestorarrow">'; 468 $rootParentId = 1; 469 if ($index > (int) ($this->treesize / 2) + (int) ($this->treesize / 4)) { 470 $rootParentId++; 471 } 472 $html .= '<a href="' . e($this->chartUrl($this->nodes[$rootParentId]['indi'], ['generations' => $generations, 'orientation' => $orientation])) . '">' . $this->arrows->prevGen . '</a>'; 473 $html .= '</div>'; 474 } else { 475 $html .= '<div class="spacer"></div>'; 476 } 477 } 478 479 return $html; 480 } 481 482 /** 483 * @return string[] 484 */ 485 protected function generationOptions(): array 486 { 487 return FunctionsEdit::numericOptions(range(static::MIN_GENERATIONS, static::MAX_GENERATIONS)); 488 } 489 490 /** 491 * @return string[] 492 */ 493 protected function orientations(): array 494 { 495 return [ 496 0 => I18N::translate('Portrait'), 497 1 => I18N::translate('Landscape'), 498 2 => I18N::translate('Oldest at top'), 499 3 => I18N::translate('Oldest at bottom'), 500 ]; 501 } 502} 503