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