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\ExtCalendar\GregorianCalendar; 25use Fisharebest\Webtrees\Auth; 26use Fisharebest\Webtrees\ColorGenerator; 27use Fisharebest\Webtrees\Date; 28use Fisharebest\Webtrees\I18N; 29use Fisharebest\Webtrees\Individual; 30use Fisharebest\Webtrees\Place; 31use Fisharebest\Webtrees\Registry; 32use Fisharebest\Webtrees\Tree; 33use Illuminate\Database\Capsule\Manager as DB; 34use Illuminate\Database\Query\JoinClause; 35use Psr\Http\Message\ResponseInterface; 36use Psr\Http\Message\ServerRequestInterface; 37use Psr\Http\Server\RequestHandlerInterface; 38 39use function app; 40use function array_filter; 41use function array_intersect; 42use function array_map; 43use function array_merge; 44use function array_reduce; 45use function array_unique; 46use function assert; 47use function count; 48use function date; 49use function explode; 50use function implode; 51use function intdiv; 52use function is_array; 53use function max; 54use function md5; 55use function min; 56use function redirect; 57use function response; 58use function route; 59use function usort; 60use function view; 61 62use const PHP_INT_MAX; 63 64/** 65 * Class LifespansChartModule 66 */ 67class LifespansChartModule extends AbstractModule implements ModuleChartInterface, RequestHandlerInterface 68{ 69 use ModuleChartTrait; 70 71 protected const ROUTE_URL = '/tree/{tree}/lifespans'; 72 73 // In theory, only "@" is a safe separator, but it gives longer and uglier URLs. 74 // Unless some other application generates XREFs with a ".", we are safe. 75 protected const SEPARATOR = '.'; 76 77 // Defaults 78 protected const DEFAULT_PARAMETERS = []; 79 80 // Parameters for generating colors 81 protected const RANGE = 120; // degrees 82 protected const SATURATION = 100; // percent 83 protected const LIGHTNESS = 30; // percent 84 protected const ALPHA = 0.25; 85 86 /** 87 * Initialization. 88 * 89 * @return void 90 */ 91 public function boot(): void 92 { 93 $router_container = app(RouterContainer::class); 94 assert($router_container instanceof RouterContainer); 95 96 $router_container->getMap() 97 ->get(static::class, static::ROUTE_URL, $this) 98 ->allows(RequestMethodInterface::METHOD_POST); 99 } 100 101 /** 102 * How should this module be identified in the control panel, etc.? 103 * 104 * @return string 105 */ 106 public function title(): string 107 { 108 /* I18N: Name of a module/chart */ 109 return I18N::translate('Lifespans'); 110 } 111 112 /** 113 * A sentence describing what this module does. 114 * 115 * @return string 116 */ 117 public function description(): string 118 { 119 /* I18N: Description of the “LifespansChart” module */ 120 return I18N::translate('A chart of individuals’ lifespans.'); 121 } 122 123 /** 124 * CSS class for the URL. 125 * 126 * @return string 127 */ 128 public function chartMenuClass(): string 129 { 130 return 'menu-chart-lifespan'; 131 } 132 133 /** 134 * The URL for this chart. 135 * 136 * @param Individual $individual 137 * @param array<bool|int|string|array|null> $parameters 138 * 139 * @return string 140 */ 141 public function chartUrl(Individual $individual, array $parameters = []): string 142 { 143 return route(static::class, [ 144 'tree' => $individual->tree()->name(), 145 'xrefs' => $individual->xref(), 146 ] + $parameters + self::DEFAULT_PARAMETERS); 147 } 148 149 /** 150 * @param ServerRequestInterface $request 151 * 152 * @return ResponseInterface 153 */ 154 public function handle(ServerRequestInterface $request): ResponseInterface 155 { 156 $tree = $request->getAttribute('tree'); 157 assert($tree instanceof Tree); 158 159 $user = $request->getAttribute('user'); 160 $xrefs = $request->getQueryParams()['xrefs'] ?? []; 161 $ajax = $request->getQueryParams()['ajax'] ?? ''; 162 163 // URLs created by older versions may already contain an array. 164 if (!is_array($xrefs)) { 165 $xrefs = explode(self::SEPARATOR, $xrefs); 166 } 167 168 $params = (array) $request->getParsedBody(); 169 170 $addxref = $params['addxref'] ?? ''; 171 $addfam = (bool) ($params['addfam'] ?? false); 172 $place_id = (int) ($params['place_id'] ?? 0); 173 $start = $params['start'] ?? ''; 174 $end = $params['end'] ?? ''; 175 176 $place = Place::find($place_id, $tree); 177 $start_date = new Date($start); 178 $end_date = new Date($end); 179 180 $xrefs = array_unique($xrefs); 181 182 // Add an individual, and family members 183 $individual = Registry::individualFactory()->make($addxref, $tree); 184 if ($individual !== null) { 185 $xrefs[] = $addxref; 186 if ($addfam) { 187 $xrefs = array_merge($xrefs, $this->closeFamily($individual)); 188 } 189 } 190 191 // Select by date and/or place. 192 if ($place_id !== 0 && $start_date->isOK() && $end_date->isOK()) { 193 $date_xrefs = $this->findIndividualsByDate($start_date, $end_date, $tree); 194 $place_xrefs = $this->findIndividualsByPlace($place, $tree); 195 $xrefs = array_intersect($date_xrefs, $place_xrefs); 196 } elseif ($start_date->isOK() && $end_date->isOK()) { 197 $xrefs = $this->findIndividualsByDate($start_date, $end_date, $tree); 198 } elseif ($place_id !== 0) { 199 $xrefs = $this->findIndividualsByPlace($place, $tree); 200 } 201 202 // Filter duplicates and private individuals. 203 $xrefs = array_unique($xrefs); 204 $xrefs = array_filter($xrefs, static function (string $xref) use ($tree): bool { 205 $individual = Registry::individualFactory()->make($xref, $tree); 206 207 return $individual !== null && $individual->canShow(); 208 }); 209 210 // Convert POST requests into GET requests for pretty URLs. 211 if ($request->getMethod() === RequestMethodInterface::METHOD_POST) { 212 return redirect(route(static::class, [ 213 'tree' => $tree->name(), 214 'xrefs' => implode(self::SEPARATOR, $xrefs), 215 ])); 216 } 217 218 Auth::checkComponentAccess($this, ModuleChartInterface::class, $tree, $user); 219 220 if ($ajax === '1') { 221 $this->layout = 'layouts/ajax'; 222 223 return $this->chart($tree, $xrefs); 224 } 225 226 $reset_url = route(static::class, ['tree' => $tree->name()]); 227 228 $ajax_url = route(static::class, [ 229 'ajax' => true, 230 'tree' => $tree->name(), 231 'xrefs' => implode(self::SEPARATOR, $xrefs), 232 ]); 233 234 return $this->viewResponse('modules/lifespans-chart/page', [ 235 'ajax_url' => $ajax_url, 236 'module' => $this->name(), 237 'reset_url' => $reset_url, 238 'title' => $this->title(), 239 'tree' => $tree, 240 'xrefs' => $xrefs, 241 ]); 242 } 243 244 /** 245 * @param Tree $tree 246 * @param array<string> $xrefs 247 * 248 * @return ResponseInterface 249 */ 250 protected function chart(Tree $tree, array $xrefs): ResponseInterface 251 { 252 /** @var Individual[] $individuals */ 253 $individuals = array_map(static function (string $xref) use ($tree): ?Individual { 254 return Registry::individualFactory()->make($xref, $tree); 255 }, $xrefs); 256 257 $individuals = array_filter($individuals, static function (?Individual $individual): bool { 258 return $individual instanceof Individual && $individual->canShow(); 259 }); 260 261 // Sort the array in order of birth year 262 usort($individuals, Individual::birthDateComparator()); 263 264 // Round to whole decades 265 $start_year = intdiv($this->minYear($individuals), 10) * 10; 266 $end_year = intdiv($this->maxYear($individuals) + 9, 10) * 10; 267 268 $lifespans = $this->layoutIndividuals($individuals); 269 270 $callback = static fn (int $carry, object $item): int => max($carry, $item->row); 271 $max_rows = array_reduce($lifespans, $callback, 0); 272 273 $count = count($xrefs); 274 $subtitle = I18N::plural('%s individual', '%s individuals', $count, I18N::number($count)); 275 276 $html = view('modules/lifespans-chart/chart', [ 277 'dir' => I18N::direction(), 278 'end_year' => $end_year, 279 'lifespans' => $lifespans, 280 'max_rows' => $max_rows, 281 'start_year' => $start_year, 282 'subtitle' => $subtitle, 283 ]); 284 285 return response($html); 286 } 287 288 /** 289 * Find the latest event year for individuals 290 * 291 * @param array<Individual> $individuals 292 * 293 * @return int 294 */ 295 protected function maxYear(array $individuals): int 296 { 297 $jd = array_reduce($individuals, static function (int $carry, Individual $item): int { 298 if ($item->getEstimatedDeathDate()->isOK()) { 299 return max($carry, $item->getEstimatedDeathDate()->maximumJulianDay()); 300 } 301 302 return $carry; 303 }, 0); 304 305 $year = $this->jdToYear($jd); 306 307 // Don't show future dates 308 return min($year, (int) date('Y')); 309 } 310 311 /** 312 * Find the earliest event year for individuals 313 * 314 * @param array<Individual> $individuals 315 * 316 * @return int 317 */ 318 protected function minYear(array $individuals): int 319 { 320 $jd = array_reduce($individuals, static function (int $carry, Individual $item): int { 321 if ($item->getEstimatedBirthDate()->isOK()) { 322 return min($carry, $item->getEstimatedBirthDate()->minimumJulianDay()); 323 } 324 325 return $carry; 326 }, PHP_INT_MAX); 327 328 return $this->jdToYear($jd); 329 } 330 331 /** 332 * Convert a julian day to a gregorian year 333 * 334 * @param int $jd 335 * 336 * @return int 337 */ 338 protected function jdToYear(int $jd): int 339 { 340 if ($jd === 0) { 341 return 0; 342 } 343 344 $gregorian = new GregorianCalendar(); 345 [$y] = $gregorian->jdToYmd($jd); 346 347 return $y; 348 } 349 350 /** 351 * @param Date $start 352 * @param Date $end 353 * @param Tree $tree 354 * 355 * @return array<string> 356 */ 357 protected function findIndividualsByDate(Date $start, Date $end, Tree $tree): array 358 { 359 return DB::table('individuals') 360 ->join('dates', static function (JoinClause $join): void { 361 $join 362 ->on('d_file', '=', 'i_file') 363 ->on('d_gid', '=', 'i_id'); 364 }) 365 ->where('i_file', '=', $tree->id()) 366 ->where('d_julianday1', '<=', $end->maximumJulianDay()) 367 ->where('d_julianday2', '>=', $start->minimumJulianDay()) 368 ->whereNotIn('d_fact', ['BAPL', 'ENDL', 'SLGC', 'SLGS', '_TODO', 'CHAN']) 369 ->pluck('i_id') 370 ->all(); 371 } 372 373 /** 374 * @param Place $place 375 * @param Tree $tree 376 * 377 * @return array<string> 378 */ 379 protected function findIndividualsByPlace(Place $place, Tree $tree): array 380 { 381 return DB::table('individuals') 382 ->join('placelinks', static function (JoinClause $join): void { 383 $join 384 ->on('pl_file', '=', 'i_file') 385 ->on('pl_gid', '=', 'i_id'); 386 }) 387 ->where('i_file', '=', $tree->id()) 388 ->where('pl_p_id', '=', $place->id()) 389 ->pluck('i_id') 390 ->all(); 391 } 392 393 /** 394 * Find the close family members of an individual. 395 * 396 * @param Individual $individual 397 * 398 * @return array<string> 399 */ 400 protected function closeFamily(Individual $individual): array 401 { 402 $xrefs = []; 403 404 foreach ($individual->spouseFamilies() as $family) { 405 foreach ($family->children() as $child) { 406 $xrefs[] = $child->xref(); 407 } 408 409 foreach ($family->spouses() as $spouse) { 410 $xrefs[] = $spouse->xref(); 411 } 412 } 413 414 foreach ($individual->childFamilies() as $family) { 415 foreach ($family->children() as $child) { 416 $xrefs[] = $child->xref(); 417 } 418 419 foreach ($family->spouses() as $spouse) { 420 $xrefs[] = $spouse->xref(); 421 } 422 } 423 424 return $xrefs; 425 } 426 427 /** 428 * @param array<Individual> $individuals 429 * 430 * @return array<object> 431 */ 432 private function layoutIndividuals(array $individuals): array 433 { 434 $colors = [ 435 'M' => new ColorGenerator(240, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE * -1), 436 'F' => new ColorGenerator(000, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE), 437 'U' => new ColorGenerator(120, self::SATURATION, self::LIGHTNESS, self::ALPHA, self::RANGE), 438 ]; 439 440 $current_year = (int) date('Y'); 441 442 // Latest year used in each row 443 $rows = []; 444 445 $lifespans = []; 446 447 foreach ($individuals as $individual) { 448 $birth_jd = $individual->getEstimatedBirthDate()->minimumJulianDay(); 449 $birth_year = $this->jdToYear($birth_jd); 450 $death_jd = $individual->getEstimatedDeathDate()->maximumJulianDay(); 451 $death_year = $this->jdToYear($death_jd); 452 453 // Died before they were born? Swapping the dates allows them to be shown. 454 if ($death_year < $birth_year) { 455 $death_year = $birth_year; 456 } 457 458 // Don't show death dates in the future. 459 $death_year = min($death_year, $current_year); 460 461 // Add this individual to the next row in the chart... 462 $next_row = count($rows); 463 // ...unless we can find an existing row where it fits. 464 foreach ($rows as $row => $year) { 465 if ($year < $birth_year) { 466 $next_row = $row; 467 break; 468 } 469 } 470 471 // Fill the row up to the year (leaving a small gap) 472 $rows[$next_row] = $death_year; 473 474 $lifespans[] = (object) [ 475 'background' => $colors[$individual->sex()]->getNextColor(), 476 'birth_year' => $birth_year, 477 'death_year' => $death_year, 478 'id' => 'individual-' . md5($individual->xref()), 479 'individual' => $individual, 480 'row' => $next_row, 481 ]; 482 } 483 484 return $lifespans; 485 } 486} 487