1<?php 2 3/** 4 * webtrees: online genealogy 5 * Copyright (C) 2021 webtrees development team 6 * This program is free software: you can redistribute it and/or modify 7 * it under the terms of the GNU General Public License as published by 8 * the Free Software Foundation, either version 3 of the License, or 9 * (at your option) any later version. 10 * This program is distributed in the hope that it will be useful, 11 * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 * GNU General Public License for more details. 14 * You should have received a copy of the GNU General Public License 15 * along with this program. If not, see <https://www.gnu.org/licenses/>. 16 */ 17 18declare(strict_types=1); 19 20namespace Fisharebest\Webtrees\Module; 21 22use Aura\Router\RouterContainer; 23use Fig\Http\Message\RequestMethodInterface; 24use Fisharebest\Webtrees\Auth; 25use Fisharebest\Webtrees\I18N; 26use Fisharebest\Webtrees\Individual; 27use Fisharebest\Webtrees\Menu; 28use Fisharebest\Webtrees\Registry; 29use Fisharebest\Webtrees\Services\ChartService; 30use Fisharebest\Webtrees\Validator; 31use Psr\Http\Message\ResponseInterface; 32use Psr\Http\Message\ServerRequestInterface; 33use Psr\Http\Server\RequestHandlerInterface; 34 35use function app; 36use function assert; 37use function max; 38use function min; 39use function route; 40use function view; 41 42/** 43 * Class PedigreeChartModule 44 */ 45class PedigreeChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface 46{ 47 use ModuleChartTrait; 48 49 protected const ROUTE_URL = '/tree/{tree}/pedigree-{style}-{generations}/{xref}'; 50 51 // Chart styles 52 public const STYLE_LEFT = 'left'; 53 public const STYLE_RIGHT = 'right'; 54 public const STYLE_UP = 'up'; 55 public const STYLE_DOWN = 'down'; 56 57 // Defaults 58 protected const DEFAULT_GENERATIONS = '4'; 59 protected const DEFAULT_STYLE = self::STYLE_RIGHT; 60 protected const DEFAULT_PARAMETERS = [ 61 'generations' => self::DEFAULT_GENERATIONS, 62 'style' => self::DEFAULT_STYLE, 63 ]; 64 65 // Limits 66 protected const MINIMUM_GENERATIONS = 2; 67 protected const MAXIMUM_GENERATIONS = 12; 68 69 // For RTL languages 70 protected const MIRROR_STYLE = [ 71 self::STYLE_UP => self::STYLE_DOWN, 72 self::STYLE_DOWN => self::STYLE_UP, 73 self::STYLE_LEFT => self::STYLE_RIGHT, 74 self::STYLE_RIGHT => self::STYLE_LEFT, 75 ]; 76 77 private ChartService $chart_service; 78 79 /** 80 * PedigreeChartModule constructor. 81 * 82 * @param ChartService $chart_service 83 */ 84 public function __construct(ChartService $chart_service) 85 { 86 $this->chart_service = $chart_service; 87 } 88 89 /** 90 * Initialization. 91 * 92 * @return void 93 */ 94 public function boot(): void 95 { 96 $router_container = app(RouterContainer::class); 97 assert($router_container instanceof RouterContainer); 98 99 $router_container->getMap() 100 ->get(static::class, static::ROUTE_URL, $this) 101 ->allows(RequestMethodInterface::METHOD_POST) 102 ->tokens([ 103 'generations' => '\d+', 104 'style' => implode('|', array_keys($this->styles('ltr'))), 105 ]); 106 } 107 108 /** 109 * How should this module be identified in the control panel, etc.? 110 * 111 * @return string 112 */ 113 public function title(): string 114 { 115 /* I18N: Name of a module/chart */ 116 return I18N::translate('Pedigree'); 117 } 118 119 /** 120 * A sentence describing what this module does. 121 * 122 * @return string 123 */ 124 public function description(): string 125 { 126 /* I18N: Description of the “PedigreeChart” module */ 127 return I18N::translate('A chart of an individual’s ancestors, formatted as a tree.'); 128 } 129 130 /** 131 * CSS class for the URL. 132 * 133 * @return string 134 */ 135 public function chartMenuClass(): string 136 { 137 return 'menu-chart-pedigree'; 138 } 139 140 /** 141 * Return a menu item for this chart - for use in individual boxes. 142 * 143 * @param Individual $individual 144 * 145 * @return Menu|null 146 */ 147 public function chartBoxMenu(Individual $individual): ?Menu 148 { 149 return $this->chartMenu($individual); 150 } 151 152 /** 153 * The title for a specific instance of this chart. 154 * 155 * @param Individual $individual 156 * 157 * @return string 158 */ 159 public function chartTitle(Individual $individual): string 160 { 161 /* I18N: %s is an individual’s name */ 162 return I18N::translate('Pedigree tree of %s', $individual->fullName()); 163 } 164 165 /** 166 * The URL for a page showing chart options. 167 * 168 * @param Individual $individual 169 * @param array<bool|int|string|array<string>|null> $parameters 170 * 171 * @return string 172 */ 173 public function chartUrl(Individual $individual, array $parameters = []): string 174 { 175 return route(static::class, [ 176 'xref' => $individual->xref(), 177 'tree' => $individual->tree()->name(), 178 ] + $parameters + static::DEFAULT_PARAMETERS); 179 } 180 181 /** 182 * @param ServerRequestInterface $request 183 * 184 * @return ResponseInterface 185 */ 186 public function handle(ServerRequestInterface $request): ResponseInterface 187 { 188 $tree = Validator::attributes($request)->tree(); 189 $xref = Validator::attributes($request)->isXref()->string('xref'); 190 $ajax = Validator::queryParams($request)->boolean('ajax', false); 191 $generations = Validator::attributes($request)->isBetween(self::MINIMUM_GENERATIONS, self::MAXIMUM_GENERATIONS)->integer('generations'); 192 $style = $request->getAttribute('style'); 193 $user = Validator::attributes($request)->user(); 194 195 // Convert POST requests into GET requests for pretty URLs. 196 if ($request->getMethod() === RequestMethodInterface::METHOD_POST) { 197 return redirect(route(self::class, [ 198 'tree' => $tree->name(), 199 'xref' => Validator::parsedBody($request)->string('xref', ''), 200 'style' => Validator::parsedBody($request)->string('style', ''), 201 'generations' => Validator::parsedBody($request)->string('generations', ''), 202 ])); 203 } 204 205 Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user); 206 207 $individual = Registry::individualFactory()->make($xref, $tree); 208 $individual = Auth::checkIndividualAccess($individual, false, true); 209 210 if ($ajax) { 211 $this->layout = 'layouts/ajax'; 212 213 $ancestors = $this->chart_service->sosaStradonitzAncestors($individual, $generations); 214 215 // Father’s ancestors link to the father’s pedigree 216 // Mother’s ancestors link to the mother’s pedigree.. 217 $links = $ancestors->map(function (?Individual $individual, $sosa) use ($ancestors, $style, $generations): string { 218 if ($individual instanceof Individual && $sosa >= 2 ** $generations / 2 && $individual->childFamilies()->isNotEmpty()) { 219 // The last row/column, and there are more generations. 220 if ($sosa >= 2 ** $generations * 3 / 4) { 221 return $this->nextLink($ancestors->get(3), $style, $generations); 222 } 223 224 return $this->nextLink($ancestors->get(2), $style, $generations); 225 } 226 227 // A spacer to fix the "Left" layout. 228 return '<span class="invisible px-2">' . view('icons/arrow-' . $style) . '</span>'; 229 }); 230 231 // Root individual links to their children. 232 $links->put(1, $this->previousLink($individual, $style, $generations)); 233 234 return $this->viewResponse('modules/pedigree-chart/chart', [ 235 'ancestors' => $ancestors, 236 'generations' => $generations, 237 'style' => $style, 238 'layout' => 'right', 239 'links' => $links, 240 'spacer' => $this->spacer(), 241 ]); 242 } 243 244 $ajax_url = $this->chartUrl($individual, [ 245 'ajax' => true, 246 'generations' => $generations, 247 'style' => $style, 248 'xref' => $xref, 249 ]); 250 251 return $this->viewResponse('modules/pedigree-chart/page', [ 252 'ajax_url' => $ajax_url, 253 'generations' => $generations, 254 'individual' => $individual, 255 'module' => $this->name(), 256 'max_generations' => self::MAXIMUM_GENERATIONS, 257 'min_generations' => self::MINIMUM_GENERATIONS, 258 'style' => $style, 259 'styles' => $this->styles(I18N::direction()), 260 'title' => $this->chartTitle($individual), 261 'tree' => $tree, 262 ]); 263 } 264 265 /** 266 * A link-sized spacer, to maintain the chart layout 267 * 268 * @return string 269 */ 270 public function spacer(): string 271 { 272 return '<span class="px-2">' . view('icons/spacer') . '</span>'; 273 } 274 275 /** 276 * Build a menu for the chart root individual 277 * 278 * @param Individual $individual 279 * @param string $style 280 * @param int $generations 281 * 282 * @return string 283 */ 284 public function nextLink(Individual $individual, string $style, int $generations): string 285 { 286 $icon = view('icons/arrow-' . $style); 287 $title = $this->chartTitle($individual); 288 $url = $this->chartUrl($individual, [ 289 'style' => $style, 290 'generations' => $generations, 291 ]); 292 293 return '<a class="px-2" href="' . e($url) . '" title="' . strip_tags($title) . '">' . $icon . '<span class="visually-hidden">' . $title . '</span></a>'; 294 } 295 296 /** 297 * Build a menu for the chart root individual 298 * 299 * @param Individual $individual 300 * @param string $style 301 * @param int $generations 302 * 303 * @return string 304 */ 305 public function previousLink(Individual $individual, string $style, int $generations): string 306 { 307 $icon = view('icons/arrow-' . self::MIRROR_STYLE[$style]); 308 309 $siblings = []; 310 $spouses = []; 311 $children = []; 312 313 foreach ($individual->childFamilies() as $family) { 314 foreach ($family->children() as $child) { 315 if ($child !== $individual) { 316 $siblings[] = $this->individualLink($child, $style, $generations); 317 } 318 } 319 } 320 321 foreach ($individual->spouseFamilies() as $family) { 322 foreach ($family->spouses() as $spouse) { 323 if ($spouse !== $individual) { 324 $spouses[] = $this->individualLink($spouse, $style, $generations); 325 } 326 } 327 328 foreach ($family->children() as $child) { 329 $children[] = $this->individualLink($child, $style, $generations); 330 } 331 } 332 333 return view('modules/pedigree-chart/previous', [ 334 'icon' => $icon, 335 'individual' => $individual, 336 'generations' => $generations, 337 'style' => $style, 338 'chart' => $this, 339 'siblings' => $siblings, 340 'spouses' => $spouses, 341 'children' => $children, 342 ]); 343 } 344 345 /** 346 * @param Individual $individual 347 * @param string $style 348 * @param int $generations 349 * 350 * @return string 351 */ 352 protected function individualLink(Individual $individual, string $style, int $generations): string 353 { 354 $text = $individual->fullName(); 355 $title = $this->chartTitle($individual); 356 $url = $this->chartUrl($individual, [ 357 'style' => $style, 358 'generations' => $generations, 359 ]); 360 361 return '<a class="dropdown-item" href="' . e($url) . '" title="' . strip_tags($title) . '">' . $text . '</a>'; 362 } 363 364 /** 365 * This chart can display its output in a number of styles 366 * 367 * @param string $direction 368 * 369 * @return array<string> 370 */ 371 protected function styles(string $direction): array 372 { 373 // On right-to-left pages, the CSS will mirror the chart, so we need to mirror the label. 374 if ($direction === 'rtl') { 375 return [ 376 self::STYLE_RIGHT => view('icons/pedigree-left') . I18N::translate('left'), 377 self::STYLE_LEFT => view('icons/pedigree-right') . I18N::translate('right'), 378 self::STYLE_UP => view('icons/pedigree-up') . I18N::translate('up'), 379 self::STYLE_DOWN => view('icons/pedigree-down') . I18N::translate('down'), 380 ]; 381 } 382 383 return [ 384 self::STYLE_LEFT => view('icons/pedigree-left') . I18N::translate('left'), 385 self::STYLE_RIGHT => view('icons/pedigree-right') . I18N::translate('right'), 386 self::STYLE_UP => view('icons/pedigree-up') . I18N::translate('up'), 387 self::STYLE_DOWN => view('icons/pedigree-down') . I18N::translate('down'), 388 ]; 389 } 390} 391