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