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