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