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