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