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