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